diff --git a/bin/index.mjs b/bin/index.mjs index a1a035b8..760ed43e 100755 --- a/bin/index.mjs +++ b/bin/index.mjs @@ -43,6 +43,10 @@ program .option( '-m, --modify ', 'Ovewrite the file from truezapps folder', + ) + .option( + '--multi-tenant', + 'Enable multi-user mode for zapps' ); program.parse(process.argv); @@ -73,6 +77,7 @@ const options = { contractsDirPath, orchestrationDirPath, modifyAST, + multiTenant: opts.multiTenant || false, }; const validateOptions = ({ diff --git a/mintAndApprove.mjs b/mintAndApprove.mjs new file mode 100644 index 00000000..648609df --- /dev/null +++ b/mintAndApprove.mjs @@ -0,0 +1,435 @@ +import fs from 'fs'; +import path from 'path'; +import { ethers } from 'ethers'; +import { fileURLToPath } from 'url'; +import { request } from 'http'; // For making HTTP requests + +// Get directory name properly in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Configuration (can be changed directly here) +// Parse arguments properly whether run directly or via npm run +function parseArgs() { + const args = process.argv.slice(2); + let tokenId = 2; + let rpcUrl = 'http://localhost:8545'; + let action = 'mint'; + let accountId = null; // For multi-tenant setup + + // Find the token ID (first numeric argument) + for (const arg of args) { + if (arg !== '--' && !isNaN(arg)) { + tokenId = parseInt(arg); + break; + } + } + + // Check for action type + if (args.includes('deposit')) { + action = 'deposit'; + } else if (args.includes('both')) { + action = 'both'; + } else if (args.includes('commitments')) { + action = 'commitments'; + } + + // Look for an URL argument + const urlArg = args.find(arg => arg.startsWith('http')); + if (urlArg) { + rpcUrl = urlArg; + } + + // Look for accountId argument (format: accountId=uuid) + const accountIdArg = args.find(arg => arg.startsWith('accountId=')); + if (accountIdArg) { + accountId = accountIdArg.split('=')[1]; + } + + console.log(`Parsed args - Token ID: ${tokenId}, RPC URL: ${rpcUrl}, Action: ${action}${accountId ? ', AccountId: ' + accountId : ''}`); + return { tokenId, rpcUrl, action, accountId }; +} + +const { tokenId: TOKEN_ID, rpcUrl: RPC_URL, action: ACTION, accountId: ACCOUNT_ID } = parseArgs(); + +async function mintAndApprove() { + try { + // Read contract ABIs and addresses + const erc721Path = path.join(__dirname, 'zapps/NFT_Escrow/build/contracts/ERC721.json'); + const shieldPath = path.join(__dirname, 'zapps/NFT_Escrow/build/contracts/NFT_EscrowShield.json'); + + console.log(`Reading ERC721 contract from ${erc721Path}`); + console.log(`Reading Shield contract from ${shieldPath}`); + + const erc721Json = JSON.parse(fs.readFileSync(erc721Path, 'utf8')); + const shieldJson = JSON.parse(fs.readFileSync(shieldPath, 'utf8')); + + // Get contract addresses from network 31337 (local hardhat network) + const ERC721_ADDRESS = erc721Json.networks['31337'].address; + const SHIELD_ADDRESS = shieldJson.networks['31337'].address; + + // Connect to local network + const provider = new ethers.providers.JsonRpcProvider(RPC_URL); + + // Get signer (using first account from local network) + const accounts = await provider.listAccounts(); + const signer = provider.getSigner(accounts[0]); + const signerAddress = accounts[0]; + + console.log('Connected to network with signer:', signerAddress); + console.log('ERC721 Contract Address:', ERC721_ADDRESS); + console.log('Shield Contract Address:', SHIELD_ADDRESS); + + // Create contract instance + const erc721Contract = new ethers.Contract(ERC721_ADDRESS, erc721Json.abi, signer); + + console.log('\n--- Minting NFT ---'); + console.log(`Minting token ID ${TOKEN_ID} to ${signerAddress}...`); + + // Mint NFT + const mintTx = await erc721Contract.mint(signerAddress, TOKEN_ID); + console.log('Mint transaction sent, waiting for confirmation...'); + const mintReceipt = await mintTx.wait(); + console.log('Mint transaction hash:', mintTx.hash); + console.log('NFT minted successfully! Gas used:', mintReceipt.gasUsed.toString()); + + // Verify ownership + const owner = await erc721Contract.ownerOf(TOKEN_ID); + console.log(`Token ${TOKEN_ID} owner:`, owner); + + console.log('\n--- Approving Shield Contract ---'); + console.log(`Approving shield contract ${SHIELD_ADDRESS} for token ${TOKEN_ID}...`); + + // Approve shield contract to transfer the NFT + const approveTx = await erc721Contract.approve(SHIELD_ADDRESS, TOKEN_ID); + console.log('Approve transaction sent, waiting for confirmation...'); + const approveReceipt = await approveTx.wait(); + console.log('Approve transaction hash:', approveTx.hash); + console.log('Shield contract approved successfully! Gas used:', approveReceipt.gasUsed.toString()); + + // Verify approval + const approvedAddress = await erc721Contract.getApproved(TOKEN_ID); + console.log(`Approved address for token ${TOKEN_ID}:`, approvedAddress); + + if (approvedAddress.toLowerCase() === SHIELD_ADDRESS.toLowerCase()) { + console.log('\n✅ Mint and approve completed successfully!'); + } else { + console.log('\n⚠️ Approval verification failed. Please check manually.'); + } + + } catch (error) { + console.error('Error:', error.message || error); + if (error.data) { + console.error('Error data:', error.data); + } + process.exit(1); + } +} + +async function depositToShield() { + try { + // Read contract ABIs and addresses + const erc721Path = path.join(__dirname, 'zapps/NFT_Escrow/build/contracts/ERC721.json'); + const shieldPath = path.join(__dirname, 'zapps/NFT_Escrow/build/contracts/NFT_EscrowShield.json'); + + console.log(`Reading contracts from build directory...`); + + const erc721Json = JSON.parse(fs.readFileSync(erc721Path, 'utf8')); + const shieldJson = JSON.parse(fs.readFileSync(shieldPath, 'utf8')); + + // Get contract addresses from network 31337 (local hardhat network) + const ERC721_ADDRESS = erc721Json.networks['31337'].address; + const SHIELD_ADDRESS = shieldJson.networks['31337'].address; + + // Connect to local network + const provider = new ethers.providers.JsonRpcProvider(RPC_URL); + + // Get signer (using first account from local network) + const accounts = await provider.listAccounts(); + const signer = provider.getSigner(accounts[0]); + const signerAddress = accounts[0]; + + console.log('Connected to network with signer:', signerAddress); + console.log('ERC721 Contract Address:', ERC721_ADDRESS); + console.log('Shield Contract Address:', SHIELD_ADDRESS); + + // Create ERC721 contract instance to check ownership and approval + const erc721Contract = new ethers.Contract(ERC721_ADDRESS, erc721Json.abi, signer); + const owner = await erc721Contract.ownerOf(TOKEN_ID); + const approvedAddress = await erc721Contract.getApproved(TOKEN_ID); + + if (owner.toLowerCase() !== signerAddress.toLowerCase()) { + console.error(`Error: You don't own token ID ${TOKEN_ID}`); + process.exit(1); + } + + if (approvedAddress.toLowerCase() !== SHIELD_ADDRESS.toLowerCase()) { + console.error(`Error: Shield contract is not approved to transfer token ID ${TOKEN_ID}`); + process.exit(1); + } + + console.log('\n--- Depositing NFT to Shield via Zapp API ---'); + console.log(`Preparing deposit for token ID ${TOKEN_ID}...`); + + // Generate a random secret for the deposit + const secret = ethers.utils.hexlify(ethers.utils.randomBytes(32)); + + // Define the Zapp API endpoint for deposit + const ZAPP_HOST = 'localhost'; + const ZAPP_PORT = 3000; // The Zapp is running on port 3000 + + // Based on the router.post("/deposit") configuration in the Zapp + const ZAPP_PATH = '/deposit'; + + console.log(`Calling Zapp API at http://${ZAPP_HOST}:${ZAPP_PORT}${ZAPP_PATH}...`); + console.log(`Depositing token ID ${TOKEN_ID} with secret: ${secret}`); + + // Create a deposit payload for the API - based on the service_deposit function + // The API only requires tokenId and optionally tokenOwners_tokenId_newOwnerPublicKey + const depositPayload = { + tokenId: TOKEN_ID, + tokenOwners_tokenId_newOwnerPublicKey: 0 // Optional parameter, using default value + }; + + // Create initial deposit info + let depositInfo = { + tokenId: TOKEN_ID, + secret: secret, + owner: signerAddress, + timestamp: new Date().toISOString() + }; + + // Make the actual HTTP request to the deposit endpoint + const depositResult = await new Promise((resolve, reject) => { + // Convert payload to JSON string + const postData = JSON.stringify(depositPayload); + + // Set up the request options + // Set up the request options + const options = { + hostname: ZAPP_HOST, + port: ZAPP_PORT, + path: ZAPP_PATH, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData) + } + }; + + // Add x-saas-context header for multi-tenant setups if accountId is provided + if (ACCOUNT_ID) { + console.log(`Using multi-tenant mode with accountId: ${ACCOUNT_ID}`); + options.headers['x-saas-context'] = JSON.stringify({ accountId: ACCOUNT_ID }); + } + + // Create the request + const req = request(options, (res) => { + let responseData = ''; + + // A chunk of data has been received + res.on('data', (chunk) => { + responseData += chunk; + }); + + // The whole response has been received + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsedData = JSON.parse(responseData); + resolve({ success: true, data: parsedData }); + } catch (e) { + resolve({ success: true, data: responseData }); + } + } else { + reject(new Error(`API request failed with status code ${res.statusCode}: ${responseData}`)); + } + }); + }); + + // Handle request errors + req.on('error', (error) => { + reject(new Error(`Error making API request: ${error.message}`)); + }); + + // Write post data and end the request + req.write(postData); + req.end(); + }); + + // Process the API response + if (depositResult.success) { + console.log('\nDeposit API call successful!'); + console.log('Response:', JSON.stringify(depositResult.data, null, 2)); + + // Update deposit info with transaction details and commitments from the response + if (depositResult.data.tx && depositResult.data.tx.transactionHash) { + depositInfo = { + ...depositInfo, + txHash: depositResult.data.tx.transactionHash, + blockNumber: depositResult.data.tx.blockNumber, + contractAddress: depositResult.data.tx.address, + commitments: depositResult.data.tx.returnValues?.leafValues || [] + }; + } + + // Also capture any direct commitment data returned by the API + if (depositResult.data.commitments) { + depositInfo.commitmentData = depositResult.data.commitments; + } else if (depositResult.data.commitment) { + depositInfo.commitmentData = depositResult.data.commitment; + } + } else { + console.error('\nDeposit API call failed!'); + throw new Error('Failed to complete deposit via API'); + } + + // Include timestamp and account information in the filename for better organization + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const accountSuffix = ACCOUNT_ID ? `-${ACCOUNT_ID}` : ''; + const depositInfoPath = path.join(__dirname, `nft-deposit-info-${TOKEN_ID}${accountSuffix}.json`); + fs.writeFileSync(depositInfoPath, JSON.stringify(depositInfo, null, 2)); + console.log(`\nDeposit information saved to: ${depositInfoPath}`); + console.log('Keep this file secure - you will need it to withdraw your NFT later!'); + + console.log('\n✅ Deposit completed successfully via API call.'); + + } catch (error) { + console.error('Error during deposit:', error.message || error); + if (error.data) { + console.error('Error data:', error.data); + } + + process.exit(1); + } +} + +// Run the appropriate script based on the action parameter +async function main() { + if (ACTION === 'mint' || ACTION === 'both') { + await mintAndApprove(); + } + + if (ACTION === 'deposit' || ACTION === 'both') { + await depositToShield(); + // After successful deposit, fetch commitments + await fetchUserCommitments(); + } + + if (ACTION === 'commitments') { + console.log('Fetching commitments only...'); + await fetchUserCommitments(); + } +} + +/** + * Fetch user commitments from the Zapp API + * This function will be called after a successful deposit + * to get all commitments associated with the user + */ +async function fetchUserCommitments() { + try { + console.log('\n--- Fetching User Commitments ---'); + + // Define the Zapp API endpoint for commitments + const ZAPP_HOST = 'localhost'; + const ZAPP_PORT = 3000; + const ZAPP_PATH = '/getAllCommitments'; // Correct endpoint from api_routes.mjs + + console.log(`Fetching commitments from http://${ZAPP_HOST}:${ZAPP_PORT}${ZAPP_PATH}...`); + + // Make HTTP request to get user's commitments + const commitmentsResult = await new Promise((resolve, reject) => { + // Set up the request options + const options = { + hostname: ZAPP_HOST, + port: ZAPP_PORT, + path: ZAPP_PATH, + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }; + + // Add x-saas-context header for multi-tenant setups if accountId is provided + if (ACCOUNT_ID) { + console.log(`Using multi-tenant mode with accountId: ${ACCOUNT_ID} for fetching commitments`); + options.headers['x-saas-context'] = JSON.stringify({ accountId: ACCOUNT_ID }); + } + + // Create the request + const req = request(options, (res) => { + let responseData = ''; + + // A chunk of data has been received + res.on('data', (chunk) => { + responseData += chunk; + }); + + // The whole response has been received + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsedData = JSON.parse(responseData); + resolve({ success: true, data: parsedData }); + } catch (e) { + resolve({ success: true, data: responseData }); + } + } else { + reject(new Error(`API request failed with status code ${res.statusCode}: ${responseData}`)); + } + }); + }); + + // Handle request errors + req.on('error', (error) => { + reject(new Error(`Error making API request: ${error.message}`)); + }); + + // End the request (no body for GET request) + req.end(); + }); + + if (commitmentsResult.success) { + console.log('\nFetch commitments successful!'); + + // Save the commitments to a file + const commitmentsData = commitmentsResult.data; + const commitmentsPath = path.join(__dirname, `user-commitments${ACCOUNT_ID ? `-${ACCOUNT_ID}` : ''}.json`); + fs.writeFileSync(commitmentsPath, JSON.stringify(commitmentsData, null, 2)); + + console.log(`Commitments saved to: ${commitmentsPath}`); + + // Handle the expected response structure where commitments are in a 'commitments' property + const commitmentsList = commitmentsData.commitments || commitmentsData; + + console.log(`Total commitments found: ${Array.isArray(commitmentsList) ? commitmentsList.length : 'unknown'}`); + + // Display some information about the commitments + if (Array.isArray(commitmentsList) && commitmentsList.length > 0) { + console.log('\nLatest commitments:'); + const latestCommitments = commitmentsList.slice(-3); // Show last 3 commitments + latestCommitments.forEach((commitment, index) => { + console.log(`[${index}] Commitment ${commitment._id || 'unknown'} for mapping key ${commitment.mappingKey || 'unknown'}`); + }); + } + + return commitmentsData; + } else { + console.error('\nFailed to fetch commitments!'); + return null; + } + + } catch (error) { + console.error('Error fetching commitments:', error.message || error); + console.log('\nFailed to fetch commitments, but deposit may have been successful.'); + return null; + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/package-lock.json b/package-lock.json index 1f484a49..a96e75da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "chai-http": "^4.3.0", "eslint": "^8.2.0", "eslint-config-codfish": "^11.1.0", + "ethers": "^5.7.2", "mocha": "^10.8.2" } }, @@ -1868,6 +1869,737 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@ethersproject/abi": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", + "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/basex": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.8.0.tgz", + "integrity": "sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/contracts": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", + "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "^5.8.0", + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/hdnode": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.8.0.tgz", + "integrity": "sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/basex": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/pbkdf2": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/wordlists": "^5.8.0" + } + }, + "node_modules/@ethersproject/json-wallets": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.8.0.tgz", + "integrity": "sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hdnode": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/pbkdf2": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "aes-js": "3.0.0", + "scrypt-js": "3.0.1" + } + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/pbkdf2": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.8.0.tgz", + "integrity": "sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/sha2": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/providers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", + "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/basex": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0", + "bech32": "1.1.4", + "ws": "8.18.0" + } + }, + "node_modules/@ethersproject/random": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz", + "integrity": "sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/sha2": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.8.0.tgz", + "integrity": "sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/solidity": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/solidity/-/solidity-5.8.0.tgz", + "integrity": "sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/units": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.8.0.tgz", + "integrity": "sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/wallet": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.8.0.tgz", + "integrity": "sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/hdnode": "^5.8.0", + "@ethersproject/json-wallets": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/wordlists": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/wordlists": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.8.0.tgz", + "integrity": "sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2503,6 +3235,13 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aes-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", + "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", + "dev": true, + "license": "MIT" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2899,6 +3638,13 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -2921,6 +3667,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", + "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -2998,6 +3751,13 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT" + }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -3848,6 +4608,29 @@ "integrity": "sha512-refir3NlutEZqlKaBLK0tzlVLe5P2wDKS7UQt/3SpibizgsRAPOsqQC3ffw1nlv3ze5gjRQZYHoPymgVZkplFA==", "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4835,6 +5618,55 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", + "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abi": "5.8.0", + "@ethersproject/abstract-provider": "5.8.0", + "@ethersproject/abstract-signer": "5.8.0", + "@ethersproject/address": "5.8.0", + "@ethersproject/base64": "5.8.0", + "@ethersproject/basex": "5.8.0", + "@ethersproject/bignumber": "5.8.0", + "@ethersproject/bytes": "5.8.0", + "@ethersproject/constants": "5.8.0", + "@ethersproject/contracts": "5.8.0", + "@ethersproject/hash": "5.8.0", + "@ethersproject/hdnode": "5.8.0", + "@ethersproject/json-wallets": "5.8.0", + "@ethersproject/keccak256": "5.8.0", + "@ethersproject/logger": "5.8.0", + "@ethersproject/networks": "5.8.0", + "@ethersproject/pbkdf2": "5.8.0", + "@ethersproject/properties": "5.8.0", + "@ethersproject/providers": "5.8.0", + "@ethersproject/random": "5.8.0", + "@ethersproject/rlp": "5.8.0", + "@ethersproject/sha2": "5.8.0", + "@ethersproject/signing-key": "5.8.0", + "@ethersproject/solidity": "5.8.0", + "@ethersproject/strings": "5.8.0", + "@ethersproject/transactions": "5.8.0", + "@ethersproject/units": "5.8.0", + "@ethersproject/wallet": "5.8.0", + "@ethersproject/web": "5.8.0", + "@ethersproject/wordlists": "5.8.0" + } + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -5614,6 +6446,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5652,6 +6495,18 @@ "node": ">=8" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -6811,6 +7666,20 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10650,6 +11519,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scrypt-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", + "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -11946,6 +12822,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index d82ca09a..c63aa713 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@types/mocha": "^9.1.0", "@types/node": "^17.0.19", "@types/prettier": "^2.4.4", + "ethers": "^5.7.2", "chai-as-promised": "^7.1.1", "chai-http": "^4.3.0", "eslint": "^8.2.0", diff --git a/src/boilerplate/common/api.mjs b/src/boilerplate/common/api.mjs index 4ed05160..e9af4ddf 100644 --- a/src/boilerplate/common/api.mjs +++ b/src/boilerplate/common/api.mjs @@ -4,6 +4,7 @@ import { ServiceManager } from './api_services.mjs'; import { Router } from './api_routes.mjs'; import Web3 from './common/web3.mjs'; ENCRYPTEDLISTENER_IMPORT +SAAS_MIDDLEWARE_IMPORT function gracefulshutdown() { console.log('Shutting down'); @@ -19,6 +20,8 @@ process.on('SIGINT', gracefulshutdown); const app = express(); app.use(express.json()); +SAAS_MIDDLEWARE_USAGE + const web3 = Web3.connection(); const serviceMgr = new ServiceManager(web3); serviceMgr.init().then(async () => { diff --git a/src/boilerplate/common/boilerplate-docker-compose.yml b/src/boilerplate/common/boilerplate-docker-compose.yml index f6ccd0d2..8f27e001 100644 --- a/src/boilerplate/common/boilerplate-docker-compose.yml +++ b/src/boilerplate/common/boilerplate-docker-compose.yml @@ -58,7 +58,7 @@ services: timber: build: - context: https://github.com/EYBlockchain/timber.git#starlight/zscaler:merkle-tree + context: https://github.com/EYBlockchain/timber.git#multiple-contracts:merkle-tree dockerfile: Dockerfile restart: on-failure depends_on: @@ -71,7 +71,7 @@ services: environment: NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/ZscalerRootCertificate-2048-SHA256.crt HASH_TYPE: 'mimc' - LOG_LEVEL: 'silly' + LOG_LEVEL: 'debug' UNIQUE_LEAVES: 'true' BLOCKCHAIN_HOST: ws://ganache BLOCKCHAIN_PORT: 8545 @@ -114,6 +114,8 @@ services: - zapp-commitment-volume:/data/db networks: - zapp_network + ports: + - '27017:27017' ganache: image: ethereumoptimism/hardhat-node diff --git a/src/boilerplate/common/commitment-storage.mjs b/src/boilerplate/common/commitment-storage.mjs index da54e583..623d59ab 100644 --- a/src/boilerplate/common/commitment-storage.mjs +++ b/src/boilerplate/common/commitment-storage.mjs @@ -10,17 +10,13 @@ import mongo from './mongo.mjs'; import logger from './logger.mjs'; import utils from 'zkp-utils'; import { poseidonHash } from './number-theory.mjs'; -import { sharedSecretKey } from './number-theory.mjs'; import { generateProof } from './zokrates.mjs'; -import { hlt } from './hash-lookup.mjs'; -import { registerKey } from './contract.mjs'; +import { KeyManager } from './key-management/KeyManager.mjs'; const { MONGO_URL, COMMITMENTS_DB, COMMITMENTS_COLLECTION } = config; const { generalise } = gen; -const keyDb = '/app/orchestration/common/db/key.json'; - -export function formatCommitment (commitment) { +export function formatCommitment (commitment, context) { let data try { const nullifierHash = commitment.secretKey @@ -42,9 +38,10 @@ export function formatCommitment (commitment) { secretKey: commitment.secretKey ? commitment.secretKey.hex(32) : null, preimage, isNullified: commitment.isNullified, - nullifier: commitment.secretKey ? nullifierHash.hex(32) : null + nullifier: commitment.secretKey ? nullifierHash.hex(32) : null, + accountId: context?.accountId || null, } - logger.debug(`Storing commitment ${data._id}`) + logger.debug(`Storing commitment ${data._id}${context?.accountId ? ` for accountId: ${context.accountId}` : ''}`) } catch (error) { console.error('Error --->', error) } @@ -57,8 +54,8 @@ export async function persistCommitment (data) { return db.collection(COMMITMENTS_COLLECTION).insertOne(data) } // function to format a commitment for a mongo db and store it -export async function storeCommitment (commitment) { - const data = formatCommitment(commitment) +export async function storeCommitment (commitment, context) { + const data = formatCommitment(commitment, context) return persistCommitment(data) } @@ -74,21 +71,27 @@ export async function getCommitmentsById(id) { } // function to retrieve commitment with a specified stateVarId -export async function getCurrentWholeCommitment(id) { +export async function getCurrentWholeCommitment(id, accountId) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); - const commitment = await db.collection(COMMITMENTS_COLLECTION).findOne({ + const query = { 'preimage.stateVarId': generalise(id).hex(32), isNullified: false, - }); + }; + + if (accountId) { + query.accountId = accountId; + } + const commitment = await db.collection(COMMITMENTS_COLLECTION).findOne(query); return commitment; } // function to retrieve commitment with a specified stateName -export async function getCommitmentsByState(name, mappingKey = null) { +export async function getCommitmentsByState(name, mappingKey = null, accountId = null) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); const query = { name: name }; + if (accountId) query['accountId'] = accountId; if (mappingKey) query['mappingKey'] = generalise(mappingKey).integer; const commitments = await db .collection(COMMITMENTS_COLLECTION) @@ -123,12 +126,13 @@ export async function getNullifiedCommitments() { /** * @returns {Promise} The sum of the values ​​of all non-nullified commitments */ -export async function getBalance() { +export async function getBalance(accountId) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); + const query = accountId ? { accountId } : {}; const commitments = await db .collection(COMMITMENTS_COLLECTION) - .find({ isNullified: false }) // no nullified + .find({ ...query, isNullified: false }) // no nullified .toArray(); let sumOfValues = 0; @@ -138,10 +142,11 @@ export async function getBalance() { return sumOfValues; } -export async function getBalanceByState(name, mappingKey = null) { +export async function getBalanceByState(name, mappingKey = null, accountId=null) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); const query = { name: name }; + if (accountId) query['accountId'] = accountId; if (mappingKey) query['mappingKey'] = generalise(mappingKey).integer; const commitments = await db .collection(COMMITMENTS_COLLECTION) @@ -159,12 +164,13 @@ export async function getBalanceByState(name, mappingKey = null) { /** * @returns all the commitments existent in this database. */ -export async function getAllCommitments() { +export async function getAllCommitments(accountId) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); + const query = accountId ? { accountId } : {}; const allCommitments = await db .collection(COMMITMENTS_COLLECTION) - .find() + .find(query) .toArray(); return allCommitments; } @@ -328,6 +334,7 @@ export async function joinCommitments( instance, contractAddr, web3, + context, ) { logger.warn( 'Existing Commitments are not appropriate and we need to call Join Commitment Circuit. It will generate proof to join commitments, this will require an on-chain verification', @@ -435,6 +442,13 @@ export async function joinCommitments( .flat(Infinity); // Send transaction to the blockchain: + // Get tenant-specific keys + const keyManager = KeyManager.getInstance(); + const keys = await keyManager.getKeys(context); + if (!keys || !keys.ethPK || !keys.ethSK) { + throw new Error('Tenant Ethereum keys not found. Please register keys first.'); + } + const txData = await instance.methods .joinCommitments( [oldCommitment_0_nullifier.integer, oldCommitment_1_nullifier.integer], @@ -445,7 +459,7 @@ export async function joinCommitments( .encodeABI(); let txParams = { - from: config.web3.options.defaultAccount, + from: keys.ethPK, to: contractAddr, gas: config.web3.options.defaultGas, gasPrice: config.web3.options.defaultGasPrice, @@ -453,7 +467,7 @@ export async function joinCommitments( chainId: await web3.eth.net.getId(), }; - const key = config.web3.key; + const key = keys.ethSK; const signed = await web3.eth.accounts.signTransaction(txParams, key); @@ -671,39 +685,30 @@ export async function splitCommitments( export async function getSharedSecretskeys( _recipientAddress, _recipientPublicKey = 0, + context, ) { - if (!fs.existsSync(keyDb)) - await registerKey(utils.randomHex(31), null, false); - const keys = JSON.parse( - fs.readFileSync(keyDb, 'utf-8', err => { - console.log(err); - }), - ); - const secretKey = generalise(keys.secretKey); - const publicKey = generalise(keys.publicKey); - let recipientPublicKey = generalise(_recipientPublicKey); - const recipientAddress = generalise(_recipientAddress); - if (_recipientPublicKey === 0) { - recipientPublicKey = await this.instance.methods - .zkpPublicKeys(recipientAddress.hex(20)) - .call(); - recipientPublicKey = generalise(recipientPublicKey); - - if (recipientPublicKey.length === 0) { - throw new Error('WARNING: Public key for given eth address not found.'); - } - } + try { + // Use KeyManager for shared secret key management + const keyManager = KeyManager.getInstance(); + + logger.debug('Getting shared secret keys via KeyManager', { + recipientAddress: _recipientAddress, + multiTenant: !!context?.accountId + }); + + const sharedPublicKey = await keyManager.getSharedSecretKeys( + _recipientAddress, + _recipientPublicKey, + context + ); - const sharedKey = sharedSecretKey(secretKey, recipientPublicKey); - console.log('sharedKey:', sharedKey); - console.log('sharedKey:', sharedKey[1]); - const keyJson = { - secretKey: secretKey.integer, - publicKey: publicKey.integer, - sharedSecretKey: sharedKey[0].integer, - sharedPublicKey: sharedKey[1].integer, // not req - }; - fs.writeFileSync(keyDb, JSON.stringify(keyJson, null, 4)); + logger.info('Shared secret keys retrieved successfully', { + multiTenant: !!context?.accountId + }); - return sharedKey[1]; + return sharedPublicKey; + } catch (error) { + logger.error('Failed to get shared secret keys:', error); + throw error; + } } diff --git a/src/boilerplate/common/config/default.js b/src/boilerplate/common/config/default.js index a483d2fd..dd0ebeff 100644 --- a/src/boilerplate/common/config/default.js +++ b/src/boilerplate/common/config/default.js @@ -1,10 +1,13 @@ module.exports = { - log_level: 'info', + LOG_LEVEL: process.env.LOG_LEVEL, + multiTenant: MULTI_TENANT_MODE, zokrates: { url: process.env.ZOKRATES_URL || 'http://zokrates:80', }, merkleTree: { url: process.env.TIMBER_URL || 'http://timber:80', + defaultMaxTries: parseInt(process.env.TIMBER_MAX_TRIES || '40', 10), // 40 tries × 3s = 2 minutes + retryDelay: parseInt(process.env.TIMBER_RETRY_DELAY || '3000', 10), // 3 seconds between retries }, // merkle-tree stuff: ZERO: '0', @@ -54,7 +57,7 @@ module.exports = { // contracts to filter: contracts: { // contract name: - CONTRACT_NAME: { + default: { treeHeight: 32, events: { // filter for the following event names: @@ -77,6 +80,7 @@ module.exports = { databaseName: 'merkle_tree', admin: 'admin', adminPassword: 'admin', + dbUrl: process.env.DB_URL || 'mongodb://admin:admin@timber-mongo:27017', }, MONGO_URL: 'mongodb://admin:admin@zapp-mongo:27017', COMMITMENTS_DB: process.env.MONGO_NAME, diff --git a/src/boilerplate/common/contract.mjs b/src/boilerplate/common/contract.mjs index 787e06e5..749375c1 100644 --- a/src/boilerplate/common/contract.mjs +++ b/src/boilerplate/common/contract.mjs @@ -4,16 +4,10 @@ import GN from 'general-number'; import utils from 'zkp-utils'; import Web3 from './web3.mjs'; import logger from './logger.mjs'; - -import { - scalarMult, - compressStarlightKey, - poseidonHash, -} from './number-theory.mjs'; +import { KeyManager } from './key-management/KeyManager.mjs'; const web3 = Web3.connection(); const { generalise } = GN; -const keyDb = '/app/orchestration/common/db/key.json'; export const contractPath = (contractName) => { return `/app/build/contracts/${contractName}.json`; @@ -118,41 +112,35 @@ export async function registerKey( _secretKey, contractName, registerWithContract, + context, ) { - let secretKey = generalise(_secretKey); - let publicKeyPoint = generalise( - scalarMult(secretKey.hex(32), config.BABYJUBJUB.GENERATOR), - ); - let publicKey = compressStarlightKey(publicKeyPoint); - while (publicKey === null) { - logger.warn(`your secret key created a large public key - resetting`); - secretKey = generalise(utils.randomHex(31)); - publicKeyPoint = generalise( - scalarMult(secretKey.hex(32), config.BABYJUBJUB.GENERATOR), + try { + // Use KeyManager for key registration + const keyManager = KeyManager.getInstance(); + + logger.debug('Registering key via KeyManager', { + contractName, + registerWithContract, + multiTenant: !!context?.accountId + }); + + const publicKeyInteger = await keyManager.registerKey( + _secretKey, + contractName, + registerWithContract, + context ); - publicKey = compressStarlightKey(publicKeyPoint); - } - if (registerWithContract) { - const instance = await getContractInstance(contractName); - const contractAddr = await getContractAddress(contractName); - const txData = await instance.methods.registerZKPPublicKey(publicKey.integer).encodeABI(); - let txParams = { - from: config.web3.options.defaultAccount, - to: contractAddr, - gas: config.web3.options.defaultGas, - gasPrice: config.web3.options.defaultGasPrice, - data: txData, - chainId: await web3.eth.net.getId(), - }; - const key = config.web3.key; - const signed = await web3.eth.accounts.signTransaction(txParams, key); - const sendTxn = await web3.eth.sendSignedTransaction(signed.rawTransaction); - } - const keyJson = { - secretKey: secretKey.integer, - publicKey: publicKey.integer, // not req - }; - fs.writeFileSync(keyDb, JSON.stringify(keyJson, null, 4)); + + const publicKey = generalise(publicKeyInteger); + + logger.info('Key registered successfully', { + publicKey: publicKey.integer, + multiTenant: !!context?.accountId + }); - return publicKey; + return publicKey; + } catch (error) { + logger.error('Failed to register key:', error); + throw error; + } } \ No newline at end of file diff --git a/src/boilerplate/common/encrypted-data-listener.mjs b/src/boilerplate/common/encrypted-data-listener.mjs index c8f89b52..37219072 100644 --- a/src/boilerplate/common/encrypted-data-listener.mjs +++ b/src/boilerplate/common/encrypted-data-listener.mjs @@ -5,8 +5,7 @@ import { generalise } from 'general-number'; import { getContractAddress, getContractInstance, registerKey } from './common/contract.mjs'; import { storeCommitment, formatCommitment, persistCommitment } from './common/commitment-storage.mjs'; import { decrypt, poseidonHash, } from './common/number-theory.mjs'; - -const keyDb = '/app/orchestration/common/db/key.json'; +import { KeyManager } from './key-management/KeyManager.mjs'; function decodeCommitmentData(decrypted){ @@ -19,10 +18,11 @@ function decodeCommitmentData(decrypted){ } export default class EncryptedDataEventListener { - constructor(web3) { + constructor(web3, context) { this.web3 = web3; - this.ethAddress = generalise(config.web3.options.defaultAccount); + this.ethAddress = null; this.contractMetadata = {}; + this.context = context; } async init() { @@ -36,12 +36,31 @@ export default class EncryptedDataEventListener { contractAddr, ); - if (!fs.existsSync(keyDb)) await registerKey(utils.randomHex(31), 'CONTRACT_NAME', true); + // Use KeyManager for key retrieval + const keyManager = KeyManager.getInstance(); + + // Check if keys exist, if not register new ones + const hasKeys = await keyManager.hasKeys(this.context); + if (!hasKeys) { + console.log('No keys found, registering new key pair...'); + await registerKey(utils.randomHex(31), 'CONTRACT_NAME', true, this.context); + } - const { secretKey, publicKey } = JSON.parse(fs.readFileSync(keyDb)); + // Retrieve keys via KeyManager + const keys = await keyManager.getKeys(this.context); - this.secretKey = generalise(secretKey); - this.publicKey = generalise(publicKey); + if (!keys) { + throw new Error('Failed to retrieve keys after registration'); + } + + this.secretKey = generalise(keys.secretKey); + this.publicKey = generalise(keys.publicKey); + this.ethAddress = keys.ethPK ? generalise(keys.ethPK) : generalise(config.web3.options.defaultAccount); + + console.log('Keys loaded successfully', { + multiTenant: !!this.context?.accountId, + ethAddress: this.ethAddress.hex(), + }); } catch (error) { console.error( 'encrypted-data-listener', diff --git a/src/boilerplate/common/gas-funding.mjs b/src/boilerplate/common/gas-funding.mjs new file mode 100644 index 00000000..ea357568 --- /dev/null +++ b/src/boilerplate/common/gas-funding.mjs @@ -0,0 +1,160 @@ +import config from 'config'; +import logger from './logger.mjs'; +import Web3 from './web3.mjs'; + +export async function fundTenantAddress(tenantAddress, amountInEther) { + const web3 = Web3.connection(); + + // Validate inputs + if (!web3.utils.isAddress(tenantAddress)) { + throw new Error(`Invalid Ethereum address: ${tenantAddress}`); + } + + if (!amountInEther || parseFloat(amountInEther) <= 0) { + throw new Error(`Invalid funding amount: ${amountInEther}`); + } + + const deployerAccount = config.web3.options.defaultAccount; + const deployerKey = config.web3.key; + + if (!deployerAccount || !deployerKey) { + throw new Error('Deployer account not configured. Set DEFAULT_ACCOUNT and KEY environment variables.'); + } + + const deployerBalance = await web3.eth.getBalance(deployerAccount); + const deployerBalanceEth = web3.utils.fromWei(deployerBalance, 'ether'); + const requiredAmount = parseFloat(amountInEther); + + if (parseFloat(deployerBalanceEth) < requiredAmount) { + throw new Error( + `Insufficient deployer balance. Required: ${requiredAmount} ETH, Available: ${deployerBalanceEth} ETH` + ); + } + + logger.info(`Funding tenant address ${tenantAddress} with ${amountInEther} ETH...`); + logger.debug(`Deployer account: ${deployerAccount}, Balance: ${deployerBalanceEth} ETH`); + + try { + const amountInWei = web3.utils.toWei(amountInEther, 'ether'); + + // Get current gas price + const gasPrice = await web3.eth.getGasPrice(); + + // Estimate gas for the transaction + const gasEstimate = await web3.eth.estimateGas({ + from: deployerAccount, + to: tenantAddress, + value: amountInWei, + }); + + // Build transaction parameters + const txParams = { + from: deployerAccount, + to: tenantAddress, + value: amountInWei, + gas: gasEstimate, + gasPrice: gasPrice, + chainId: await web3.eth.net.getId(), + }; + + // Sign transaction with deployer's private key + const signedTx = await web3.eth.accounts.signTransaction(txParams, deployerKey); + + // Send signed transaction + const receipt = await web3.eth.sendSignedTransaction(signedTx.rawTransaction); + + logger.info( + `Successfully funded ${tenantAddress} with ${amountInEther} ETH. Tx hash: ${receipt.transactionHash}` + ); + + return receipt; + } catch (error) { + logger.error(`Failed to fund tenant address ${tenantAddress}:`, error.message); + throw new Error(`Gas funding failed: ${error.message}`); + } +} + +export async function hasSufficientGas(tenantAddress, minimumBalanceInEther) { + const web3 = Web3.connection(); + + // Validate inputs + if (!web3.utils.isAddress(tenantAddress)) { + throw new Error(`Invalid Ethereum address: ${tenantAddress}`); + } + + if (!minimumBalanceInEther || parseFloat(minimumBalanceInEther) < 0) { + throw new Error(`Invalid minimum balance: ${minimumBalanceInEther}`); + } + + try { + // Get current balance + const balanceWei = await web3.eth.getBalance(tenantAddress); + const balanceEth = web3.utils.fromWei(balanceWei, 'ether'); + const minimumBalance = parseFloat(minimumBalanceInEther); + + const hasSufficient = parseFloat(balanceEth) >= minimumBalance; + + logger.debug( + `Address ${tenantAddress} balance: ${balanceEth} ETH (minimum: ${minimumBalance} ETH) - ${ + hasSufficient ? 'Sufficient' : 'Insufficient' + }` + ); + + return hasSufficient; + } catch (error) { + logger.error(`Failed to check balance for ${tenantAddress}:`, error.message); + throw new Error(`Balance check failed: ${error.message}`); + } +} + +export async function autoFundIfNeeded( + tenantAddress, + minimumBalanceInEther = '0.01', + fundAmountInEther = '0.1' +) { + logger.debug(`Checking if tenant address ${tenantAddress} needs gas funding...`); + + try { + // Check if address already has sufficient balance + const hasSufficient = await hasSufficientGas(tenantAddress, minimumBalanceInEther); + + if (hasSufficient) { + logger.debug(`Tenant address ${tenantAddress} already has sufficient gas. No funding needed.`); + return null; + } + + logger.info( + `Tenant address ${tenantAddress} has insufficient gas (< ${minimumBalanceInEther} ETH). Auto-funding with ${fundAmountInEther} ETH...` + ); + + const receipt = await fundTenantAddress(tenantAddress, fundAmountInEther); + + logger.info(`Auto-funding complete for ${tenantAddress}. Ready to send transactions!`); + + return receipt; + } catch (error) { + logger.error(`Auto-funding failed for ${tenantAddress}:`, error.message); + throw new Error(`Auto-funding failed: ${error.message}`); + } +} + +export async function getTenantBalance(tenantAddress) { + const web3 = Web3.connection(); + + if (!web3.utils.isAddress(tenantAddress)) { + throw new Error(`Invalid Ethereum address: ${tenantAddress}`); + } + + const balanceWei = await web3.eth.getBalance(tenantAddress); + const balanceEth = web3.utils.fromWei(balanceWei, 'ether'); + + return balanceEth; +} + +export default { + fundTenantAddress, + hasSufficientGas, + autoFundIfNeeded, + getTenantBalance, +}; + diff --git a/src/boilerplate/common/key-management/DatabaseKeyStorage.mjs b/src/boilerplate/common/key-management/DatabaseKeyStorage.mjs new file mode 100644 index 00000000..0065fd65 --- /dev/null +++ b/src/boilerplate/common/key-management/DatabaseKeyStorage.mjs @@ -0,0 +1,411 @@ +/** + * @file DatabaseKeyStorage.mjs + * @description MongoDB-based key storage implementation for multi-tenant deployments. + * Provides complete isolation between users based on accountId. + */ + +import config from 'config'; +import GN from 'general-number'; +import utils from 'zkp-utils'; +import mongo from '../mongo.mjs'; +import logger from '../logger.mjs'; +import { IKeyStorage } from './IKeyStorage.mjs'; +import { encryptIfEnabled, decryptIfEncrypted } from './encryption.mjs'; +import { + scalarMult, + compressStarlightKey, + sharedSecretKey, +} from '../number-theory.mjs'; + +const { generalise } = GN; + +// Configuration +const MONGO_URL = process.env.MONGO_URL || config.MONGO_URL || 'mongodb://localhost:27017'; +const KEYS_DB = process.env.KEYS_DB || config.KEYS_DB || config.COMMITMENTS_DB || 'starlight_db'; +const USER_KEYS_COLLECTION = 'user_keys'; + +/** + * Database-based key storage implementation. + * Stores keys in MongoDB with complete isolation between users. + * Supports encryption at rest for sensitive key material. + * + * @extends IKeyStorage + */ +export class DatabaseKeyStorage extends IKeyStorage { + constructor() { + super(); + this.mongoUrl = MONGO_URL; + this.dbName = KEYS_DB; + this.collectionName = USER_KEYS_COLLECTION; + } + + /** + * Get MongoDB collection instance. + * + * @returns {Promise} + * @private + */ + async getCollection() { + const connection = await mongo.connection(this.mongoUrl); + const db = connection.db(this.dbName); + return db.collection(this.collectionName); + } + + /** + * Validate that context is provided and contains accountId. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} context + * @throws {Error} If context is missing or invalid + * @private + */ + validateContext(context) { + if (!context || !context.accountId) { + throw new Error( + 'DatabaseKeyStorage requires a valid SaaS context with accountId. ' + + 'Ensure x-saas-context header is present in the request.' + ); + } + + // Validate accountId format (alphanumeric, hyphens, underscores only) + if (!/^[a-zA-Z0-9_-]+$/.test(context.accountId)) { + throw new Error('Invalid accountId format. Must contain only alphanumeric characters, hyphens, and underscores.'); + } + } + + /** + * Retrieve keys for a user from the database. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} context - Required SaaS context + * @returns {Promise} User keys or null if not found + */ + async getKeys(context) { + this.validateContext(context); + + try { + const collection = await this.getCollection(); + const doc = await collection.findOne({ accountId: context.accountId }); + + if (!doc) { + logger.debug(`No keys found for accountId: ${context.accountId}`); + return null; + } + + // Update lastUsed timestamp + await collection.updateOne( + { accountId: context.accountId }, + { + $set: { + 'metadata.lastUsed': new Date(), + updatedAt: new Date() + } + } + ); + + // Decrypt sensitive keys + const keys = { + secretKey: decryptIfEncrypted(doc.secretKey), + publicKey: doc.publicKey, // Public key doesn't need decryption + ethSK: doc.ethSK ? decryptIfEncrypted(doc.ethSK) : null, // Ethereum private key (encrypted) + ethPK: doc.ethPK || null, + }; + + if (doc.sharedSecretKey) { + keys.sharedSecretKey = decryptIfEncrypted(doc.sharedSecretKey); + } + if (doc.sharedPublicKey) { + keys.sharedPublicKey = doc.sharedPublicKey; + } + + logger.debug(`Keys retrieved for accountId: ${context.accountId}`); + return keys; + } catch (error) { + logger.error(`Error retrieving keys for accountId ${context.accountId}:`, error); + throw new Error(`Failed to retrieve keys: ${error.message}`); + } + } + + /** + * Save keys for a user to the database. + * + * @param {import('./IKeyStorage.mjs').UserKeys} keys - User keys to save + * @param {import('./IKeyStorage.mjs').SaaSContext} context - Required SaaS context + * @returns {Promise} + */ + async saveKeys(keys, context) { + this.validateContext(context); + + try { + const collection = await this.getCollection(); + const now = new Date(); + + // Encrypt sensitive keys + const doc = { + accountId: context.accountId, + secretKey: encryptIfEnabled(keys.secretKey), + publicKey: keys.publicKey, // Public key doesn't need encryption + updatedAt: now, + }; + + // Include Ethereum keys if present + if (keys.ethSK) { + doc.ethSK = encryptIfEnabled(keys.ethSK); + } + if (keys.ethPK) { + doc.ethPK = keys.ethPK; + } + + // Include optional shared keys if present + if (keys.sharedSecretKey) { + doc.sharedSecretKey = encryptIfEnabled(keys.sharedSecretKey); + } + if (keys.sharedPublicKey) { + doc.sharedPublicKey = keys.sharedPublicKey; + } + + // Upsert: update if exists, insert if not + const result = await collection.updateOne( + { accountId: context.accountId }, + { + $set: doc, + $setOnInsert: { + createdAt: now, + metadata: { + keyVersion: 1, + registeredOnChain: false, + } + } + }, + { upsert: true } + ); + + if (result.upsertedCount > 0) { + logger.info(`Keys created for accountId: ${context.accountId}`); + } else { + logger.debug(`Keys updated for accountId: ${context.accountId}`); + } + } catch (error) { + logger.error(`Error saving keys for accountId ${context.accountId}:`, error); + throw new Error(`Failed to save keys: ${error.message}`); + } + } + + /** + * Register a new key pair. + * + * @param {string} _secretKey - Secret key to register (hex string) + * @param {string} contractName - Associated contract name + * @param {boolean} registerWithContract - Whether to register the key on-chain + * @param {import('./IKeyStorage.mjs').SaaSContext} context - Required SaaS context + * @returns {Promise} Public key (as integer string) + */ + async registerKey(_secretKey, contractName, registerWithContract, context) { + this.validateContext(context); + + try { + let secretKey = generalise(_secretKey); + let publicKeyPoint = generalise( + scalarMult(secretKey.hex(32), config.BABYJUBJUB.GENERATOR) + ); + let publicKey = compressStarlightKey(publicKeyPoint); + + // Regenerate if public key is too large + while (publicKey === null) { + logger.warn('Secret key created a large public key - regenerating'); + secretKey = generalise(utils.randomHex(31)); + publicKeyPoint = generalise( + scalarMult(secretKey.hex(32), config.BABYJUBJUB.GENERATOR) + ); + publicKey = compressStarlightKey(publicKeyPoint); + } + + const Web3 = await import('../web3.mjs'); + const web3 = Web3.default.connection(); + const ethAccount = web3.eth.accounts.create(); + const ethSK = ethAccount.privateKey; + const ethPK = ethAccount.address; + + logger.info(`Generated Ethereum address for tenant ${context.accountId}: ${ethPK}`); + + // AUTO-FUND tenant address with gas + const { autoFundIfNeeded } = await import('../gas-funding.mjs'); + await autoFundIfNeeded(ethPK, '0.1', '0.5'); + logger.info(`Auto-funded tenant address ${ethPK} with gas. Ready to send transactions!`); + + // Register on-chain if requested + if (registerWithContract) { + const { getContractInstance, getContractAddress } = await import('../contract.mjs'); + + const instance = await getContractInstance(contractName); + const contractAddr = await getContractAddress(contractName); + const txData = await instance.methods + .registerZKPPublicKey(publicKey.integer) + .encodeABI(); + + const txParams = { + from: ethPK, + to: contractAddr, + gas: config.web3.options.defaultGas, + gasPrice: config.web3.options.defaultGasPrice, + data: txData, + chainId: await web3.eth.net.getId(), + }; + + const signed = await web3.eth.accounts.signTransaction(txParams, ethSK); + await web3.eth.sendSignedTransaction(signed.rawTransaction); + logger.info(`Key registered on-chain for accountId: ${context.accountId}`); + } + + // Save keys to database with metadata + await this.saveKeys({ + secretKey: secretKey.integer, + publicKey: publicKey.integer, + ethSK, + ethPK, + }, context); + + // Update metadata + const collection = await this.getCollection(); + await collection.updateOne( + { accountId: context.accountId }, + { + $set: { + 'metadata.contractName': contractName, + 'metadata.registeredOnChain': registerWithContract, + } + } + ); + + logger.info(`Key registered successfully for accountId: ${context.accountId}`); + return publicKey.integer; + } catch (error) { + logger.error(`Error registering key for accountId ${context.accountId}:`, error); + throw new Error(`Failed to register key: ${error.message}`); + } + } + + /** + * Get or create shared secret keys for encrypted communication. + * + * @param {string} _recipientAddress - Recipient's Ethereum address + * @param {string|number} _recipientPublicKey - Recipient's public key (0 to fetch from contract) + * @param {import('./IKeyStorage.mjs').SaaSContext} context - Required SaaS context + * @returns {Promise} Shared public key + */ + async getSharedSecretKeys(_recipientAddress, _recipientPublicKey = 0, context) { + this.validateContext(context); + + try { + // Ensure keys exist + let keys = await this.getKeys(context); + if (!keys) { + await this.registerKey(utils.randomHex(31), null, false, context); + keys = await this.getKeys(context); + } + + const secretKey = generalise(keys.secretKey); + const publicKey = generalise(keys.publicKey); + let recipientPublicKey = generalise(_recipientPublicKey); + const recipientAddress = generalise(_recipientAddress); + + // Fetch recipient's public key from contract if not provided + if (_recipientPublicKey === 0) { + const { getContractInstance } = await import('../contract.mjs'); + const instance = await getContractInstance('CONTRACT_NAME'); + + recipientPublicKey = await instance.methods + .zkpPublicKeys(recipientAddress.hex(20)) + .call(); + recipientPublicKey = generalise(recipientPublicKey); + + if (recipientPublicKey.length === 0) { + throw new Error('Public key for given eth address not found'); + } + } + + // Generate shared secret + const sharedKey = sharedSecretKey(secretKey, recipientPublicKey); + logger.debug(`Shared key generated for accountId: ${context.accountId}`); + + // Update keys with shared secret + await this.saveKeys({ + secretKey: secretKey.integer, + publicKey: publicKey.integer, + sharedSecretKey: sharedKey[0].integer, + sharedPublicKey: sharedKey[1].integer, + }, context); + + return sharedKey[1]; + } catch (error) { + logger.error(`Error getting shared secret keys for accountId ${context.accountId}:`, error); + throw new Error(`Failed to get shared secret keys: ${error.message}`); + } + } + + /** + * Check if keys exist for a user in the database. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} context - Required SaaS context + * @returns {Promise} True if keys exist + */ + async hasKeys(context) { + this.validateContext(context); + + try { + const collection = await this.getCollection(); + const count = await collection.countDocuments({ accountId: context.accountId }); + return count > 0; + } catch (error) { + logger.error(`Error checking keys for accountId ${context.accountId}:`, error); + throw new Error(`Failed to check keys: ${error.message}`); + } + } + + /** + * Delete keys for a user from the database. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} context - Required SaaS context + * @returns {Promise} True if keys were deleted, false if they didn't exist + */ + async deleteKeys(context) { + this.validateContext(context); + + try { + const collection = await this.getCollection(); + const result = await collection.deleteOne({ accountId: context.accountId }); + + if (result.deletedCount > 0) { + logger.info(`Keys deleted for accountId: ${context.accountId}`); + return true; + } + + logger.debug(`No keys found to delete for accountId: ${context.accountId}`); + return false; + } catch (error) { + logger.error(`Error deleting keys for accountId ${context.accountId}:`, error); + throw new Error(`Failed to delete keys: ${error.message}`); + } + } + + async getAccountIdByEthAddress(ethAddress) { + try { + const collection = await this.getCollection(); + const doc = await collection.findOne( + { ethPK: ethAddress }, + { projection: { accountId: 1 } } + ); + + if (!doc) { + logger.debug(`No accountId found for Ethereum address: ${ethAddress}`); + return null; + } + + logger.debug(`Found accountId ${doc.accountId} for Ethereum address ${ethAddress}`); + return doc.accountId; + } catch (error) { + logger.error(`Error looking up accountId for Ethereum address ${ethAddress}:`, error); + throw new Error(`Failed to lookup accountId: ${error.message}`); + } + } +} + +export default DatabaseKeyStorage; diff --git a/src/boilerplate/common/key-management/FileKeyStorage.mjs b/src/boilerplate/common/key-management/FileKeyStorage.mjs new file mode 100644 index 00000000..eb66de51 --- /dev/null +++ b/src/boilerplate/common/key-management/FileKeyStorage.mjs @@ -0,0 +1,264 @@ +/** + * @file FileKeyStorage.mjs + * @description File-based key storage implementation. + * This wraps the existing key.json file-based logic for backward compatibility. + */ + +import fs from 'fs'; +import config from 'config'; +import GN from 'general-number'; +import utils from 'zkp-utils'; +import logger from '../logger.mjs'; +import { IKeyStorage } from './IKeyStorage.mjs'; +import { + scalarMult, + compressStarlightKey, + sharedSecretKey, +} from '../number-theory.mjs'; + +const { generalise } = GN; +const keyDb = '/app/orchestration/common/db/key.json'; + +/** + * File-based key storage implementation. + * Stores keys in a single JSON file at /app/orchestration/common/db/key.json + * This is the legacy/default storage mechanism for single-tenant deployments. + * + * @extends IKeyStorage + */ +export class FileKeyStorage extends IKeyStorage { + constructor() { + super(); + this.keyFilePath = keyDb; + } + + /** + * Retrieve keys from the key.json file. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Ignored for file storage + * @returns {Promise} User keys or null if file doesn't exist + */ + async getKeys(context) { + try { + if (!fs.existsSync(this.keyFilePath)) { + logger.debug('Key file does not exist'); + return null; + } + + const keyData = fs.readFileSync(this.keyFilePath, 'utf-8'); + const keys = JSON.parse(keyData); + + logger.debug('Keys retrieved from file'); + return { + secretKey: keys.secretKey, + publicKey: keys.publicKey, + sharedSecretKey: keys.sharedSecretKey, + sharedPublicKey: keys.sharedPublicKey, + }; + } catch (error) { + logger.error('Error reading keys from file:', error); + throw new Error(`Failed to read keys from file: ${error.message}`); + } + } + + /** + * Save keys to the key.json file. + * + * @param {import('./IKeyStorage.mjs').UserKeys} keys - User keys to save + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Ignored for file storage + * @returns {Promise} + */ + async saveKeys(keys, context) { + try { + // Ensure directory exists + const dir = '/app/orchestration/common/db'; + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const keyJson = { + secretKey: keys.secretKey, + publicKey: keys.publicKey, + }; + + // Include optional shared keys if present + if (keys.sharedSecretKey) { + keyJson.sharedSecretKey = keys.sharedSecretKey; + } + if (keys.sharedPublicKey) { + keyJson.sharedPublicKey = keys.sharedPublicKey; + } + + fs.writeFileSync(this.keyFilePath, JSON.stringify(keyJson, null, 4)); + logger.debug('Keys saved to file'); + } catch (error) { + logger.error('Error saving keys to file:', error); + throw new Error(`Failed to save keys to file: ${error.message}`); + } + } + + /** + * Register a new key pair. + * This replicates the logic from contract.mjs registerKey() function. + * + * @param {string} _secretKey - Secret key to register (hex string) + * @param {string} contractName - Associated contract name + * @param {boolean} registerWithContract - Whether to register the key on-chain + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Ignored for file storage + * @returns {Promise} Public key (as integer string) + */ + async registerKey(_secretKey, contractName, registerWithContract, context) { + try { + let secretKey = generalise(_secretKey); + let publicKeyPoint = generalise( + scalarMult(secretKey.hex(32), config.BABYJUBJUB.GENERATOR) + ); + let publicKey = compressStarlightKey(publicKeyPoint); + + // Regenerate if public key is too large + while (publicKey === null) { + logger.warn('Secret key created a large public key - regenerating'); + secretKey = generalise(utils.randomHex(31)); + publicKeyPoint = generalise( + scalarMult(secretKey.hex(32), config.BABYJUBJUB.GENERATOR) + ); + publicKey = compressStarlightKey(publicKeyPoint); + } + + // Register on-chain if requested + if (registerWithContract) { + // Import here to avoid circular dependency + const { getContractInstance, getContractAddress } = await import('../contract.mjs'); + const Web3 = await import('../web3.mjs'); + const web3 = Web3.default.connection(); + + const instance = await getContractInstance(contractName); + const contractAddr = await getContractAddress(contractName); + const txData = await instance.methods + .registerZKPPublicKey(publicKey.integer) + .encodeABI(); + + const txParams = { + from: config.web3.options.defaultAccount, + to: contractAddr, + gas: config.web3.options.defaultGas, + gasPrice: config.web3.options.defaultGasPrice, + data: txData, + chainId: await web3.eth.net.getId(), + }; + + const key = config.web3.key; + const signed = await web3.eth.accounts.signTransaction(txParams, key); + await web3.eth.sendSignedTransaction(signed.rawTransaction); + logger.info('Key registered on-chain'); + } + + // Save keys to file + await this.saveKeys({ + secretKey: secretKey.integer, + publicKey: publicKey.integer, + }); + + logger.info('Key registered successfully'); + return publicKey.integer; + } catch (error) { + logger.error('Error registering key:', error); + throw new Error(`Failed to register key: ${error.message}`); + } + } + + /** + * Get or create shared secret keys for encrypted communication. + * This replicates the logic from commitment-storage.mjs getSharedSecretskeys() function. + * + * @param {string} _recipientAddress - Recipient's Ethereum address + * @param {string|number} _recipientPublicKey - Recipient's public key (0 to fetch from contract) + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Ignored for file storage + * @returns {Promise} Shared public key + */ + async getSharedSecretKeys(_recipientAddress, _recipientPublicKey = 0, context) { + try { + // Ensure keys exist + if (!fs.existsSync(this.keyFilePath)) { + await this.registerKey(utils.randomHex(31), null, false); + } + + const keys = await this.getKeys(); + const secretKey = generalise(keys.secretKey); + const publicKey = generalise(keys.publicKey); + let recipientPublicKey = generalise(_recipientPublicKey); + const recipientAddress = generalise(_recipientAddress); + + // Fetch recipient's public key from contract if not provided + if (_recipientPublicKey === 0) { + // Import here to avoid circular dependency + const { getContractInstance } = await import('../contract.mjs'); + const instance = await getContractInstance('CONTRACT_NAME'); + + recipientPublicKey = await instance.methods + .zkpPublicKeys(recipientAddress.hex(20)) + .call(); + recipientPublicKey = generalise(recipientPublicKey); + + if (recipientPublicKey.length === 0) { + throw new Error('Public key for given eth address not found'); + } + } + + // Generate shared secret + const sharedKey = sharedSecretKey(secretKey, recipientPublicKey); + logger.debug('Shared key generated:', sharedKey[1]); + + // Update keys with shared secret + await this.saveKeys({ + secretKey: secretKey.integer, + publicKey: publicKey.integer, + sharedSecretKey: sharedKey[0].integer, + sharedPublicKey: sharedKey[1].integer, + }); + + return sharedKey[1]; + } catch (error) { + logger.error('Error getting shared secret keys:', error); + throw new Error(`Failed to get shared secret keys: ${error.message}`); + } + } + + /** + * Check if keys exist in the file. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Ignored for file storage + * @returns {Promise} True if key file exists + */ + async hasKeys(context) { + return fs.existsSync(this.keyFilePath); + } + + /** + * Delete the key file. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Ignored for file storage + * @returns {Promise} True if file was deleted, false if it didn't exist + */ + async deleteKeys(context) { + try { + if (fs.existsSync(this.keyFilePath)) { + fs.unlinkSync(this.keyFilePath); + logger.info('Key file deleted'); + return true; + } + return false; + } catch (error) { + logger.error('Error deleting key file:', error); + throw new Error(`Failed to delete key file: ${error.message}`); + } + } + + async getAccountIdByEthAddress(ethAddress) { + logger.debug(`getAccountIdByEthAddress not supported in single-tenant mode (address: ${ethAddress})`); + return null; + } +} + +export default FileKeyStorage; + diff --git a/src/boilerplate/common/key-management/IKeyStorage.mjs b/src/boilerplate/common/key-management/IKeyStorage.mjs new file mode 100644 index 00000000..f650e0a1 --- /dev/null +++ b/src/boilerplate/common/key-management/IKeyStorage.mjs @@ -0,0 +1,116 @@ +/** + * @file IKeyStorage.mjs + * @description Interface definition for key storage implementations. + * This file defines the contract that both FileKeyStorage and DatabaseKeyStorage must implement. + */ + +/** + * @typedef {Object} SaaSContext + * @property {string} accountId - Unique identifier for the user/account in multi-tenant mode + */ + +/** + * @typedef {Object} UserKeys + * @property {string} secretKey - User's ZKP secret key (as integer string) + * @property {string} publicKey - User's ZKP public key (as integer string) + * @property {string} ethSK - User's Ethereum private key + * @property {string} ethPK - User's Ethereum address + * @property {string} [sharedSecretKey] - Optional shared secret key for encrypted communication + * @property {string} [sharedPublicKey] - Optional shared public key + */ + +/** + * @typedef {Object} KeyMetadata + * @property {number} keyVersion - Version number for key rotation support + * @property {string} contractName - Associated contract name + * @property {boolean} registeredOnChain - Whether the key is registered on-chain + * @property {Date} [lastUsed] - Last time the key was accessed + */ + +/** + * Base class for key storage implementations. + * This class defines the interface that all key storage implementations must follow. + * + * @abstract + */ +export class IKeyStorage { + /** + * Retrieve keys for a user. + * + * @param {SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} User keys or null if not found + * @abstract + */ + async getKeys(context) { + throw new Error('getKeys() must be implemented by subclass'); + } + + /** + * Save keys for a user. + * + * @param {UserKeys} keys - User keys to save + * @param {SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} + * @abstract + */ + async saveKeys(keys, context) { + throw new Error('saveKeys() must be implemented by subclass'); + } + + /** + * Register a new key pair. + * Generates a public key from the secret key and optionally registers it on-chain. + * + * @param {string} secretKey - Secret key to register (hex string) + * @param {string} contractName - Associated contract name + * @param {boolean} registerWithContract - Whether to register the key on-chain + * @param {SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} Public key (as integer string) + * @abstract + */ + async registerKey(secretKey, contractName, registerWithContract, context) { + throw new Error('registerKey() must be implemented by subclass'); + } + + /** + * Get or create shared secret keys for encrypted communication with another user. + * + * @param {string} recipientAddress - Recipient's Ethereum address + * @param {string|number} recipientPublicKey - Recipient's public key + * @param {SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} Shared public key + * @abstract + */ + async getSharedSecretKeys(recipientAddress, recipientPublicKey, context) { + throw new Error('getSharedSecretKeys() must be implemented by subclass'); + } + + /** + * Check if keys exist for a user. + * + * @param {SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} True if keys exist, false otherwise + * @abstract + */ + async hasKeys(context) { + throw new Error('hasKeys() must be implemented by subclass'); + } + + /** + * Delete keys for a user (optional, for key rotation or cleanup). + * + * @param {SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} True if keys were deleted, false if they didn't exist + * @abstract + */ + async deleteKeys(context) { + throw new Error('deleteKeys() must be implemented by subclass'); + } + + async getAccountIdByEthAddress(ethAddress) { + throw new Error('getAccountIdByEthAddress() must be implemented by subclass'); + } +} + +export default IKeyStorage; + diff --git a/src/boilerplate/common/key-management/KeyManager.mjs b/src/boilerplate/common/key-management/KeyManager.mjs new file mode 100644 index 00000000..0ec5e2d2 --- /dev/null +++ b/src/boilerplate/common/key-management/KeyManager.mjs @@ -0,0 +1,221 @@ +/** + * @file KeyManager.mjs + * @description Singleton key manager that routes to appropriate storage based on context. + * Provides a unified interface for key management that works in both single-tenant (file-based) + * and multi-tenant (database-based) modes. + */ + +import logger from '../logger.mjs'; +import FileKeyStorage from './FileKeyStorage.mjs'; +import DatabaseKeyStorage from './DatabaseKeyStorage.mjs'; + +/** + * KeyManager singleton class. + * Routes key operations to FileKeyStorage or DatabaseKeyStorage based on context. + * + * Usage: + * const keyManager = KeyManager.getInstance(); + * + * // Single-tenant mode (no context) + * const keys = await keyManager.getKeys(); + * + * // Multi-tenant mode (with context) + * const keys = await keyManager.getKeys({ accountId: 'user-123' }); + */ +export class KeyManager { + /** + * @private + * @type {KeyManager} + */ + static instance = null; + + /** + * @private + */ + constructor() { + if (KeyManager.instance) { + throw new Error('KeyManager is a singleton. Use KeyManager.getInstance() instead.'); + } + + this.fileStorage = new FileKeyStorage(); + this.dbStorage = new DatabaseKeyStorage(); + + logger.debug('KeyManager initialized'); + } + + /** + * Get the singleton instance of KeyManager. + * + * @returns {KeyManager} + */ + static getInstance() { + if (!KeyManager.instance) { + KeyManager.instance = new KeyManager(); + } + return KeyManager.instance; + } + + /** + * Reset the singleton instance (useful for testing). + * + * @private + */ + static resetInstance() { + KeyManager.instance = null; + } + + /** + * Get the appropriate storage implementation based on context. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context + * @returns {import('./IKeyStorage.mjs').IKeyStorage} Storage implementation + * @private + */ + getStorage(context) { + if (context && context.accountId) { + logger.debug(`Using DatabaseKeyStorage for accountId: ${context.accountId}`); + return this.dbStorage; + } + + logger.debug('Using FileKeyStorage (single-tenant mode)'); + return this.fileStorage; + } + + /** + * Retrieve keys for a user. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} User keys or null if not found + */ + async getKeys(context) { + try { + const storage = this.getStorage(context); + return await storage.getKeys(context); + } catch (error) { + logger.error('KeyManager.getKeys failed:', error); + throw error; + } + } + + /** + * Save keys for a user. + * + * @param {import('./IKeyStorage.mjs').UserKeys} keys - User keys to save + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} + */ + async saveKeys(keys, context) { + try { + const storage = this.getStorage(context); + return await storage.saveKeys(keys, context); + } catch (error) { + logger.error('KeyManager.saveKeys failed:', error); + throw error; + } + } + + /** + * Register a new key pair. + * + * @param {string} secretKey - Secret key to register (hex string) + * @param {string} contractName - Associated contract name + * @param {boolean} registerWithContract - Whether to register the key on-chain + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} Public key (as integer string) + */ + async registerKey(secretKey, contractName, registerWithContract, context) { + try { + const storage = this.getStorage(context); + return await storage.registerKey(secretKey, contractName, registerWithContract, context); + } catch (error) { + logger.error('KeyManager.registerKey failed:', error); + throw error; + } + } + + /** + * Get or create shared secret keys for encrypted communication. + * + * @param {string} recipientAddress - Recipient's Ethereum address + * @param {string|number} recipientPublicKey - Recipient's public key (0 to fetch from contract) + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} Shared public key + */ + async getSharedSecretKeys(recipientAddress, recipientPublicKey, context) { + try { + const storage = this.getStorage(context); + return await storage.getSharedSecretKeys(recipientAddress, recipientPublicKey, context); + } catch (error) { + logger.error('KeyManager.getSharedSecretKeys failed:', error); + throw error; + } + } + + /** + * Check if keys exist for a user. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} True if keys exist, false otherwise + */ + async hasKeys(context) { + try { + const storage = this.getStorage(context); + return await storage.hasKeys(context); + } catch (error) { + logger.error('KeyManager.hasKeys failed:', error); + throw error; + } + } + + /** + * Delete keys for a user. + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context for multi-tenant mode + * @returns {Promise} True if keys were deleted, false if they didn't exist + */ + async deleteKeys(context) { + try { + const storage = this.getStorage(context); + return await storage.deleteKeys(context); + } catch (error) { + logger.error('KeyManager.deleteKeys failed:', error); + throw error; + } + } + + /** + * Get storage mode information (for debugging/monitoring). + * + * @param {import('./IKeyStorage.mjs').SaaSContext} [context] - Optional SaaS context + * @returns {Object} Storage mode information + */ + getStorageInfo(context) { + const storage = this.getStorage(context); + return { + mode: storage instanceof DatabaseKeyStorage ? 'database' : 'file', + multiTenant: !!context?.accountId, + accountId: context?.accountId || null, + }; + } + + async getAccountIdByEthAddress(ethAddress) { + try { + return await this.dbStorage.getAccountIdByEthAddress(ethAddress); + } catch (error) { + logger.error('KeyManager.getAccountIdByEthAddress failed:', error); + throw error; + } + } +} + +/** + * Convenience function to get the KeyManager instance. + * + * @returns {KeyManager} + */ +export function getKeyManager() { + return KeyManager.getInstance(); +} + +export default KeyManager; + diff --git a/src/boilerplate/common/key-management/encryption.mjs b/src/boilerplate/common/key-management/encryption.mjs new file mode 100644 index 00000000..4d866551 --- /dev/null +++ b/src/boilerplate/common/key-management/encryption.mjs @@ -0,0 +1,208 @@ +/** + * @file encryption.mjs + * @description Encryption utilities for securing sensitive key data at rest. + * Uses AES-256-GCM for authenticated encryption. + */ + +import crypto from 'crypto'; +import logger from '../logger.mjs'; + +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 16; // 128 bits +const AUTH_TAG_LENGTH = 16; // 128 bits +const SALT_LENGTH = 32; // 256 bits + +/** + * Get or generate the encryption key from environment variable. + * The key should be a 64-character hex string (32 bytes). + * + * @returns {Buffer} Encryption key + * @throws {Error} If KEY_ENCRYPTION_KEY is not set or invalid + */ +function getEncryptionKey() { + const keyHex = process.env.KEY_ENCRYPTION_KEY; + + if (!keyHex) { + // In development/single-tenant mode, we can use a default key + // In production multi-tenant mode, this MUST be set + const defaultKey = '0'.repeat(64); // 32 bytes of zeros + logger.warn( + 'KEY_ENCRYPTION_KEY environment variable not set. Using default key. ' + + 'THIS IS INSECURE FOR PRODUCTION USE!' + ); + return Buffer.from(defaultKey, 'hex'); + } + + // Validate key format + if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) { + throw new Error( + 'KEY_ENCRYPTION_KEY must be a 64-character hexadecimal string (32 bytes)' + ); + } + + return Buffer.from(keyHex, 'hex'); +} + +/** + * Check if encryption is enabled. + * Encryption is enabled if KEY_ENCRYPTION_ENABLED is set to 'true' or if running in multi-tenant mode. + * + * @returns {boolean} True if encryption is enabled + */ +export function isEncryptionEnabled() { + return process.env.KEY_ENCRYPTION_ENABLED === 'true'; +} + +/** + * Encrypt a plaintext value using AES-256-GCM. + * + * Format: encrypted:AES256GCM:iv:authTag:ciphertext + * All components are hex-encoded. + * + * @param {string} plaintext - The value to encrypt + * @returns {string} Encrypted value in the format above + * @throws {Error} If encryption fails + */ +export function encrypt(plaintext) { + try { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let ciphertext = cipher.update(plaintext, 'utf8', 'hex'); + ciphertext += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Format: encrypted:AES256GCM:iv:authTag:ciphertext + const encrypted = [ + 'encrypted', + 'AES256GCM', + iv.toString('hex'), + authTag.toString('hex'), + ciphertext + ].join(':'); + + logger.debug('Value encrypted successfully'); + return encrypted; + } catch (error) { + logger.error('Encryption failed:', error); + throw new Error(`Encryption failed: ${error.message}`); + } +} + +/** + * Decrypt a value that was encrypted with the encrypt() function. + * + * @param {string} encryptedValue - The encrypted value to decrypt + * @returns {string} Decrypted plaintext + * @throws {Error} If decryption fails or format is invalid + */ +export function decrypt(encryptedValue) { + try { + // Check if value is encrypted + if (!encryptedValue.startsWith('encrypted:AES256GCM:')) { + throw new Error('Invalid encrypted value format: missing prefix'); + } + + const parts = encryptedValue.split(':'); + if (parts.length !== 5) { + throw new Error( + `Invalid encrypted value format: expected 5 parts, got ${parts.length}` + ); + } + + const [prefix, algorithm, ivHex, authTagHex, ciphertext] = parts; + + // Validate components + if (prefix !== 'encrypted' || algorithm !== 'AES256GCM') { + throw new Error('Invalid encrypted value format: invalid prefix or algorithm'); + } + + const key = getEncryptionKey(); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + // Validate lengths + if (iv.length !== IV_LENGTH) { + throw new Error(`Invalid IV length: expected ${IV_LENGTH}, got ${iv.length}`); + } + if (authTag.length !== AUTH_TAG_LENGTH) { + throw new Error(`Invalid auth tag length: expected ${AUTH_TAG_LENGTH}, got ${authTag.length}`); + } + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let plaintext = decipher.update(ciphertext, 'hex', 'utf8'); + plaintext += decipher.final('utf8'); + + logger.debug('Value decrypted successfully'); + return plaintext; + } catch (error) { + logger.error('Decryption failed:', error); + throw new Error(`Decryption failed: ${error.message}`); + } +} + +/** + * Check if a value is encrypted. + * + * @param {string} value - The value to check + * @returns {boolean} True if the value appears to be encrypted + */ +export function isEncrypted(value) { + return typeof value === 'string' && value.startsWith('encrypted:AES256GCM:'); +} + +/** + * Conditionally encrypt a value based on whether encryption is enabled. + * If encryption is disabled, returns the value as-is. + * + * @param {string} value - The value to potentially encrypt + * @returns {string} Encrypted value or original value + */ +export function encryptIfEnabled(value) { + if (isEncryptionEnabled()) { + return encrypt(value); + } + return value; +} + +/** + * Conditionally decrypt a value if it's encrypted. + * If the value is not encrypted, returns it as-is. + * + * @param {string} value - The value to potentially decrypt + * @returns {string} Decrypted value or original value + */ +export function decryptIfEncrypted(value) { + if (isEncrypted(value)) { + return decrypt(value); + } + return value; +} + +/** + * Generate a random encryption key suitable for KEY_ENCRYPTION_KEY. + * This is a utility function for initial setup. + * + * @returns {string} 64-character hex string (32 bytes) + */ +export function generateEncryptionKey() { + const key = crypto.randomBytes(KEY_LENGTH); + return key.toString('hex'); +} + +export default { + encrypt, + decrypt, + isEncrypted, + encryptIfEnabled, + decryptIfEncrypted, + isEncryptionEnabled, + generateEncryptionKey +}; + diff --git a/src/boilerplate/common/key-management/index.mjs b/src/boilerplate/common/key-management/index.mjs new file mode 100644 index 00000000..f7eb0403 --- /dev/null +++ b/src/boilerplate/common/key-management/index.mjs @@ -0,0 +1,73 @@ +/** + * @file index.mjs + * @description Main entry point for the key management system. + * Exports all key management components for easy importing. + */ + +// Core components +export { IKeyStorage } from './IKeyStorage.mjs'; +export { FileKeyStorage } from './FileKeyStorage.mjs'; +export { DatabaseKeyStorage } from './DatabaseKeyStorage.mjs'; +export { KeyManager, getKeyManager } from './KeyManager.mjs'; + +// Encryption utilities +export { + encrypt, + decrypt, + isEncrypted, + encryptIfEnabled, + decryptIfEncrypted, + isEncryptionEnabled, + generateEncryptionKey +} from './encryption.mjs'; + +// Middleware +export { + saasContextMiddleware, + requireSaasContext, + forbidSaasContext, + getSaasContext, + isMultiTenant +} from '../middleware/saas-context.mjs'; + +/** + * Convenience function to get a configured KeyManager instance. + * This is the recommended way to access key management functionality. + * + * @returns {KeyManager} + * + * @example + * import { getKeyManager } from './key-management/index.mjs'; + * + * const keyManager = getKeyManager(); + * const keys = await keyManager.getKeys(req.saasContext); + */ +export function getKeyManager() { + return KeyManager.getInstance(); +} + +export default { + // Core + IKeyStorage, + FileKeyStorage, + DatabaseKeyStorage, + KeyManager, + getKeyManager, + + // Encryption + encrypt, + decrypt, + isEncrypted, + encryptIfEnabled, + decryptIfEncrypted, + isEncryptionEnabled, + generateEncryptionKey, + + // Middleware + saasContextMiddleware, + requireSaasContext, + forbidSaasContext, + getSaasContext, + isMultiTenant, +}; + diff --git a/src/boilerplate/common/middleware/saas-context.mjs b/src/boilerplate/common/middleware/saas-context.mjs new file mode 100644 index 00000000..f6cea6e9 --- /dev/null +++ b/src/boilerplate/common/middleware/saas-context.mjs @@ -0,0 +1,194 @@ +/** + * @file saas-context.mjs + * @description Express middleware for parsing and validating x-saas-context header. + * This middleware enables multi-tenant mode by extracting the accountId from the request header. + */ + +import logger from '../logger.mjs'; +import config from 'config'; + +/** + * Middleware to parse and validate the x-saas-context header. + * + * Header format: + * x-saas-context: {"accountId": "user-123"} + * + * Behavior depends on config.multiTenant setting: + * - If config.multiTenant is true (strict mode): + * * Header is REQUIRED - returns 400 if missing + * * All requests must include valid x-saas-context header + * - If config.multiTenant is false (permissive mode): + * * Header is optional - proceeds in single-tenant mode if missing + * * Backward compatible with single-tenant deployments + * + * If the header is present and valid, attaches req.saasContext with the parsed data. + * If the header is present but invalid, returns a 400 error. + * + * @param {import('express').Request} req - Express request object + * @param {import('express').Response} res - Express response object + * @param {import('express').NextFunction} next - Express next function + */ +export function saasContextMiddleware(req, res, next) { + try { + const headerValue = req.headers['x-saas-context']; + const isStrictMode = config.multiTenant === true; + + // If header is not present + if (!headerValue) { + // In strict multi-tenant mode, header is required + if (isStrictMode) { + logger.warn('x-saas-context header required in multi-tenant mode but not provided'); + return res.status(400).json({ + error: 'SaaS context required', + message: 'This application is running in multi-tenant mode and requires the x-saas-context header', + example: '{"accountId": "user-123"}', + hint: 'Add the x-saas-context header to your request' + }); + } + + // In permissive mode, proceed in single-tenant mode + logger.debug('No x-saas-context header - using single-tenant mode'); + req.saasContext = undefined; + return next(); + } + + // Parse the header value + let context; + try { + context = JSON.parse(headerValue); + } catch (parseError) { + logger.warn('Invalid JSON in x-saas-context header:', parseError); + return res.status(400).json({ + error: 'Invalid x-saas-context header', + message: 'Header value must be valid JSON', + example: '{"accountId": "user-123"}' + }); + } + + // Validate accountId is present + if (!context.accountId) { + logger.warn('x-saas-context header missing accountId'); + return res.status(400).json({ + error: 'Invalid x-saas-context header', + message: 'accountId is required', + example: '{"accountId": "user-123"}' + }); + } + + // Validate accountId is a string + if (typeof context.accountId !== 'string') { + logger.warn('x-saas-context accountId is not a string:', typeof context.accountId); + return res.status(400).json({ + error: 'Invalid x-saas-context header', + message: 'accountId must be a string', + received: typeof context.accountId + }); + } + + // Validate accountId format (alphanumeric, hyphens, underscores only) + // This prevents injection attacks and ensures compatibility with database queries + if (!/^[a-zA-Z0-9_-]+$/.test(context.accountId)) { + logger.warn('x-saas-context accountId has invalid format:', context.accountId); + return res.status(400).json({ + error: 'Invalid x-saas-context header', + message: 'accountId must contain only alphanumeric characters, hyphens, and underscores', + pattern: '^[a-zA-Z0-9_-]+$', + received: context.accountId + }); + } + + // Validate accountId length (prevent excessively long IDs) + if (context.accountId.length > 128) { + logger.warn('x-saas-context accountId too long:', context.accountId.length); + return res.status(400).json({ + error: 'Invalid x-saas-context header', + message: 'accountId must be 128 characters or less', + received: context.accountId.length + }); + } + + // Attach validated context to request + req.saasContext = { + accountId: context.accountId + }; + + logger.debug(`SaaS context set for accountId: ${context.accountId}`); + next(); + } catch (error) { + // Catch any unexpected errors + logger.error('Unexpected error in saasContextMiddleware:', error); + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to process x-saas-context header' + }); + } +} + +/** + * Middleware to require SaaS context (multi-tenant mode). + * Use this middleware on routes that MUST have a SaaS context. + * + * @param {import('express').Request} req - Express request object + * @param {import('express').Response} res - Express response object + * @param {import('express').NextFunction} next - Express next function + */ +export function requireSaasContext(req, res, next) { + if (!req.saasContext || !req.saasContext.accountId) { + logger.warn('SaaS context required but not provided'); + return res.status(400).json({ + error: 'SaaS context required', + message: 'This endpoint requires the x-saas-context header', + example: '{"accountId": "user-123"}' + }); + } + next(); +} + +/** + * Middleware to forbid SaaS context (single-tenant mode only). + * Use this middleware on routes that should NOT accept a SaaS context. + * + * @param {import('express').Request} req - Express request object + * @param {import('express').Response} res - Express response object + * @param {import('express').NextFunction} next - Express next function + */ +export function forbidSaasContext(req, res, next) { + if (req.saasContext && req.saasContext.accountId) { + logger.warn('SaaS context provided but not allowed on this endpoint'); + return res.status(400).json({ + error: 'SaaS context not allowed', + message: 'This endpoint does not support multi-tenant mode' + }); + } + next(); +} + +/** + * Get the SaaS context from a request object. + * Returns undefined if no context is present (single-tenant mode). + * + * @param {import('express').Request} req - Express request object + * @returns {import('../key-management/IKeyStorage.mjs').SaaSContext|undefined} + */ +export function getSaasContext(req) { + return req.saasContext; +} + +/** + * Check if a request is in multi-tenant mode. + * + * @param {import('express').Request} req - Express request object + * @returns {boolean} + */ +export function isMultiTenant(req) { + return !!(req.saasContext && req.saasContext.accountId); +} + +export default { + saasContextMiddleware, + requireSaasContext, + forbidSaasContext, + getSaasContext, + isMultiTenant +}; + diff --git a/src/boilerplate/common/services/generic-api_services.mjs b/src/boilerplate/common/services/generic-api_services.mjs index 0a7ed169..c118feef 100644 --- a/src/boilerplate/common/services/generic-api_services.mjs +++ b/src/boilerplate/common/services/generic-api_services.mjs @@ -38,7 +38,8 @@ export class ServiceManager{ try { await startEventFilter('CONTRACT_NAME'); const FUNCTION_SIG; - const { tx , encEvent, encBackupEvent, _RESPONSE_} = await this.FUNCTION_NAME.FUNCTION_NAME(FUNCTION_SIG); + SAAS_CONTEXT_HANDLING + const { tx , encEvent, encBackupEvent, _RESPONSE_} = await this.FUNCTION_NAME.FUNCTION_NAME(FUNCTION_SIG, SAAS_CONTEXT_PARAM); // prints the tx console.log(tx); const txSerialized = serializeBigInt(tx); diff --git a/src/boilerplate/common/services/generic-read-only-api_services.mjs b/src/boilerplate/common/services/generic-read-only-api_services.mjs index 2a441a3e..603c5b5c 100644 --- a/src/boilerplate/common/services/generic-read-only-api_services.mjs +++ b/src/boilerplate/common/services/generic-read-only-api_services.mjs @@ -38,7 +38,8 @@ export class ServiceManager{ try { await startEventFilter('CONTRACT_NAME'); const FUNCTION_SIG; - const { _RESPONSE_} = await this.FUNCTION_NAME.FUNCTION_NAME(FUNCTION_SIG); + SAAS_CONTEXT_HANDLING + const { _RESPONSE_} = await this.FUNCTION_NAME.FUNCTION_NAME(FUNCTION_SIG, SAAS_CONTEXT_PARAM); res.send({_RESPONSE_ }); await sleep(10); } catch (err) { diff --git a/src/boilerplate/common/services/genericpublic-api_services.mjs b/src/boilerplate/common/services/genericpublic-api_services.mjs index cef1cf98..02d81515 100644 --- a/src/boilerplate/common/services/genericpublic-api_services.mjs +++ b/src/boilerplate/common/services/genericpublic-api_services.mjs @@ -11,7 +11,8 @@ let encryption = {}; // eslint-disable-next-line func-names async service_FUNCTION_NAME (req, res, next){ const FUNCTION_SIG; - const { tx , _RESPONSE_} = await this.FUNCTION_NAME.FUNCTION_NAME(FUNCTION_SIG); + SAAS_CONTEXT_HANDLING + const { tx , _RESPONSE_} = await this.FUNCTION_NAME.FUNCTION_NAME(FUNCTION_SIG, SAAS_CONTEXT_PARAM); // prints the tx console.log(tx); const txSerialized = serializeBigInt(tx); diff --git a/src/boilerplate/common/services/saas-key-services.mjs b/src/boilerplate/common/services/saas-key-services.mjs new file mode 100644 index 00000000..199f8175 --- /dev/null +++ b/src/boilerplate/common/services/saas-key-services.mjs @@ -0,0 +1,150 @@ +/* eslint-disable prettier/prettier, camelcase, prefer-const, no-unused-vars */ +import config from "config"; +import logger from "./common/logger.mjs"; +import { + getOrCreateKeys, + extractAccountId, + getKeysFromDB, + storeKeysInDB, + generateKeyPair +} from "./common/key-management.mjs"; + +export async function service_getSharedKeys(req, res) { + try { + const accountId = extractAccountId(req); + const { targetAccountId } = req.body; + + if (!targetAccountId) { + return res.status(400).send({ error: 'targetAccountId is required' }); + } + + let sharedKeys = {}; + + if (accountId) { + // SaaS mode - get keys from database + const currentUserKeys = await getKeysFromDB(accountId); + const targetUserKeys = await getKeysFromDB(targetAccountId); + + if (!currentUserKeys || !targetUserKeys) { + return res.status(404).send({ error: 'Keys not found for one or both accounts' }); + } + + sharedKeys = { + currentUserPublicKey: currentUserKeys.publicKey, + targetUserPublicKey: targetUserKeys.publicKey, + sharedPublicKey: currentUserKeys.sharedPublicKey, + sharedSecretKey: currentUserKeys.sharedSecretKey, + }; + } else { + // File mode - return error as shared keys require multi-user context + return res.status(400).send({ error: 'Shared keys require SaaS context (multi-user mode)' }); + } + + res.send({ sharedKeys }); + } catch (err) { + logger.error('Error getting shared keys:', err); + res.status(500).send({ error: err.message }); + } +} + +/** + * Service to rotate keys for a user + */ +export async function service_rotateKeys(req, res) { + try { + const accountId = extractAccountId(req); + const { contractName, registerWithContract = false } = req.body; + + if (!accountId) { + return res.status(400).send({ error: 'SaaS context required for key rotation' }); + } + + // Generate new keys + const newKeys = generateKeyPair(); + + // Store new keys + await storeKeysInDB(accountId, newKeys); + + // Register with contract if requested + if (registerWithContract && contractName) { + const { registerKeyWithContract } = await import('./common/key-management.mjs'); + await registerKeyWithContract(newKeys.publicKey, contractName); + } + + res.send({ + message: 'Keys rotated successfully', + publicKey: newKeys.publicKey, + sharedPublicKey: newKeys.sharedPublicKey + }); + } catch (err) { + logger.error('Error rotating keys:', err); + res.status(500).send({ error: err.message }); + } +} + +/** + * Service to get current user's public keys + */ +export async function service_getUserKeys(req, res) { + try { + const accountId = extractAccountId(req); + + if (!accountId) { + return res.status(400).send({ error: 'SaaS context required' }); + } + + const keys = await getKeysFromDB(accountId); + + if (!keys) { + return res.status(404).send({ error: 'Keys not found' }); + } + + // Only return public keys for security + const publicKeys = { + publicKey: keys.publicKey, + sharedPublicKey: keys.sharedPublicKey, + accountId: accountId, + }; + + res.send({ keys: publicKeys }); + } catch (err) { + logger.error('Error getting user keys:', err); + res.status(500).send({ error: err.message }); + } +} + +/** + * Service to initialize keys for a new user + */ +export async function service_initializeUserKeys(req, res) { + try { + const accountId = extractAccountId(req); + const { contractName, registerWithContract = false } = req.body; + + if (!accountId) { + return res.status(400).send({ error: 'SaaS context required for user key initialization' }); + } + + // Check if keys already exist + const existingKeys = await getKeysFromDB(accountId); + if (existingKeys) { + return res.status(409).send({ + error: 'Keys already exist for this account', + publicKey: existingKeys.publicKey, + sharedPublicKey: existingKeys.sharedPublicKey + }); + } + + // Get or create keys (will create new ones since they don't exist) + const keys = await getOrCreateKeys(accountId, contractName, registerWithContract); + + res.send({ + message: 'Keys initialized successfully', + publicKey: keys.publicKey, + sharedPublicKey: keys.sharedPublicKey + }); + } catch (err) { + logger.error('Error initializing user keys:', err); + res.status(500).send({ error: err.message }); + } +} diff --git a/src/boilerplate/common/timber.mjs b/src/boilerplate/common/timber.mjs index ada451db..3fab5a11 100644 --- a/src/boilerplate/common/timber.mjs +++ b/src/boilerplate/common/timber.mjs @@ -50,7 +50,16 @@ export const getLeafIndex = async ( let leafIndex; let errorCount = 0; const limit = - typeof maxTries === 'number' && !isNaN(maxTries) ? maxTries : 20; + typeof maxTries === 'number' && !isNaN(maxTries) + ? maxTries + : (config.merkleTree.defaultMaxTries || 40); + + // Track timing for performance monitoring + const startTime = Date.now(); + + let consecutiveNulls = 0; + const resyncThreshold = config.merkleTree?.resyncThreshold || 5; + while (errorCount < limit) { try { // eslint-disable-next-line no-await-in-loop @@ -71,20 +80,46 @@ export const getLeafIndex = async ( logger.http('Timber Response:', response.data.data); if (response.data.data !== null) { leafIndex = response.data.data.leafIndex; - if (leafIndex) break; + if (leafIndex) { + const elapsedMs = Date.now() - startTime; + const elapsedSec = (elapsedMs / 1000).toFixed(2); + if (errorCount === 0) { + logger.info(`Timber: Leaf already indexed for ${contractName} (leafIndex: ${leafIndex}, commitment: ${value.substring(0, 20)}...)`); + } else { + logger.info(`Timber: Leaf successfully indexed for ${contractName} after ${errorCount + 1} attempts in ${elapsedSec}s (leafIndex: ${leafIndex}, commitment: ${value.substring(0, 20)}...)`); + } + break; + } break; } else { + consecutiveNulls++; + + if (consecutiveNulls === resyncThreshold) { + try { + await getRoot(contractName, contractAddress); + logger.info("Timber resynced successfully"); + } catch (err) { + logger.warn(`Failed to trigger resync: ${err.message}`) + } + } throw new Error('leaf not found'); } } catch (err) { errorCount++; - logger.warn('Unable to get leaf - will try again in 3 seconds'); + const retryDelay = config.merkleTree.retryDelay || 3000; + logger.warn(`Unable to get leaf - will try again in ${retryDelay / 1000} seconds (attempt ${errorCount}/${limit})`); // eslint-disable-next-line no-await-in-loop await new Promise(resolve => { - setTimeout(() => resolve(), 3000); + setTimeout(() => resolve(), retryDelay); }); } } + + if (leafIndex === undefined) { + const elapsedMs = Date.now() - startTime; + const elapsedSec = (elapsedMs / 1000).toFixed(2); + logger.error(`Timber: Leaf NOT found for ${contractName} after ${errorCount} attempts in ${elapsedSec}s (commitment: ${value.substring(0, 20)}...)`); + } return leafIndex; }; export const getRoot = async (contractName, address) => { @@ -155,15 +190,25 @@ export const getSiblingPath = async (contractName, leafIndex, leafValue) => { } return siblingPath; }; -export const getMembershipWitness = async (contractName, leafValue) => { +export const getMembershipWitness = async (contractName, leafValue, maxTries) => { logger.http(`\nCalling getMembershipWitness for ${contractName} tree`); try { - const leafIndex = await getLeafIndex(contractName, leafValue); + const tries = typeof maxTries === 'number' && !isNaN(maxTries) + ? maxTries + : config.merkleTree?.defaultMaxTries; + const leafIndex = await getLeafIndex(contractName, leafValue, undefined, tries); + + if (undefined === leafIndex) { + const totalWaitTime = (tries * (config.merkleTree.retryDelay || 3000)) / 1000; + throw new Error(`Commitment not found in Timber after ${tries} attempts (${totalWaitTime}s total wait time)`) + } + let path = await getSiblingPath(contractName, leafIndex); const root = path[0].value; path = path.map(node => node.value); path.splice(0, 1); const witness = { index: leafIndex, path, root }; + logger.info("Membership witness generated successfully"); return witness; } catch (error) { throw new Error(error); diff --git a/src/boilerplate/contract/solidity/raw/ContractBoilerplateGenerator.ts b/src/boilerplate/contract/solidity/raw/ContractBoilerplateGenerator.ts index 911c7dbf..139251e2 100644 --- a/src/boilerplate/contract/solidity/raw/ContractBoilerplateGenerator.ts +++ b/src/boilerplate/contract/solidity/raw/ContractBoilerplateGenerator.ts @@ -94,7 +94,8 @@ class ContractBoilerplateGenerator { ]; }, - + // NOTE: zkpPublicKeys[msg.sender] gets oevrwritten when each user regisyters + // That means only the last user will have their key mapped properly registerZKPPublicKey(): string[] { return [ ` @@ -218,7 +219,18 @@ class ContractBoilerplateGenerator { 'customInputs.length', ...(newNullifiers ? ['newNullifiers.length'] : []), ...(checkNullifiers ? ['checkNullifiers.length']: []), - ...(commitmentRoot ? ['(newNullifiers.length > 0 ? 1 : 0)'] : []), + ...(() => { + if (commitmentRoot){ + if (checkNullifiers && newNullifiers) { + return ['((newNullifiers.length + checkNullifiers.length) > 0 ? 1 : 0)']; + } + if (checkNullifiers) { + return ['((checkNullifiers.length) > 0 ? 1 : 0)']; + } + return ['(newNullifiers.length > 0 ? 1 : 0)']; + } + return []; + })(), ...(newCommitments ? ['newCommitments.length'] : []), ...(encryptionRequired ? ['encInputsLen'] : []), ].join(' + ')});`, diff --git a/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts b/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts index 43fd6aa8..0a973207 100644 --- a/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts +++ b/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts @@ -268,11 +268,12 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { export function buildBoilerplateNode(nodeType: string, fields: any = {}): any { switch (nodeType) { case 'InitialiseKeys': { - const { onChainKeyRegistry, contractName } = fields; + const { onChainKeyRegistry, contractName, msgSenderParam } = fields; return { nodeType, contractName, onChainKeyRegistry, + msgSenderParam, }; } case 'InitialisePreimage': { @@ -434,6 +435,7 @@ export function buildBoilerplateNode(nodeType: string, fields: any = {}): any { functions = [], constructorParams = [], contractImports = [], + multiTenant = false, } = fields; return { nodeType, @@ -442,6 +444,7 @@ export function buildBoilerplateNode(nodeType: string, fields: any = {}): any { functions, constructorParams, contractImports, + multiTenant, }; } case 'IntegrationApiRoutesBoilerplate': { diff --git a/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts b/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts index adaafc7f..45260361 100644 --- a/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts +++ b/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts @@ -42,7 +42,7 @@ class BoilerplateGenerator { \n // Initialise commitment preimage of whole accessed state: ${stateVarIds.join('\n')} \nlet ${stateName}_commitmentExists = true; - \nconst ${stateName}_commitment = await getCurrentWholeCommitment(${stateName}_stateVarId); + \nconst ${stateName}_commitment = await getCurrentWholeCommitment(${stateName}_stateVarId, SAAS_CONTEXT_PARAM?.accountId); \nconst ${stateName}_preimage = ${stateName}_commitment.preimage; \nconst ${stateName} = generalise(${stateName}_preimage.value);`]; default: @@ -51,7 +51,7 @@ class BoilerplateGenerator { ${stateVarIds.join('\n')} \nlet ${stateName}_commitmentExists = true; let ${stateName}_witnessRequired = true; - \nconst ${stateName}_commitment = await getCurrentWholeCommitment(${stateName}_stateVarId); + \nconst ${stateName}_commitment = await getCurrentWholeCommitment(${stateName}_stateVarId, SAAS_CONTEXT_PARAM?.accountId); \nlet ${stateName}_preimage = { \tvalue: ${structProperties ? `{` + structProperties.map(p => `${p}: 0`) + `}` : `0`}, \tsalt: 0, @@ -70,20 +70,22 @@ class BoilerplateGenerator { initialiseKeys = { - postStatements(contractName, onChainKeyRegistry): string[] { + postStatements(contractName, onChainKeyRegistry, msgSenderParam): string[] { + const msgSenderLine = msgSenderParam ? `\nconst msgSender = generalise(keys.ethPK);` : ''; return [ ` - \n\n// Read dbs for keys and previous commitment values: - \nif (!fs.existsSync(keyDb)) await registerKey(utils.randomHex(31), '${contractName}', ${onChainKeyRegistry}); - const keys = JSON.parse( - fs.readFileSync(keyDb, 'utf-8', err => { - console.log(err); - }), - ); - const secretKey = generalise(keys.secretKey); - const publicKey = generalise(keys.publicKey); - const sharedPublicKey = generalise(keys.sharedPublicKey); - const sharedSecretKey = generalise(keys.sharedSecretKey); + \n\n// Read keys using KeyManager + \nconst keyManager = KeyManager.getInstance(); + \nlet keys = await keyManager.getKeys(context); + \nif (!keys) { + \n // No keys found, register new ones + \n await registerKey(utils.randomHex(31), '${contractName}', ${onChainKeyRegistry}, context); + \n keys = await keyManager.getKeys(context); + \n} + \nconst secretKey = generalise(keys.secretKey); + \nconst publicKey = generalise(keys.publicKey); + \nconst sharedPublicKey = keys.sharedPublicKey ? generalise(keys.sharedPublicKey) : null; + \nconst sharedSecretKey = keys.sharedSecretKey ? generalise(keys.sharedSecretKey) : null;${msgSenderLine} ` ]; }, @@ -234,7 +236,7 @@ class BoilerplateGenerator { if(${stateName}_1_oldCommitment === null && ${stateName}_commitmentFlag){ \n${stateName}_witness_0 = await getMembershipWitness('${contractName}', generalise(${stateName}_0_oldCommitment._id).integer); - \n const tx = await splitCommitments('${contractName}', '${mappingName}', ${stateName}_newCommitmentValue, secretKey, publicKey, [${stateVarId.join(' , ')}], ${stateName}_0_oldCommitment, ${stateName}_witness_0, instance, contractAddr, web3); + \n const tx = await splitCommitments('${contractName}', '${mappingName}', ${stateName}_newCommitmentValue, secretKey, publicKey, [${stateVarId.join(' , ')}], ${stateName}_0_oldCommitment, ${stateName}_witness_0, instance, contractAddr, web3, context); ${stateName}_preimage = await getCommitmentsById(${stateName}_stateVarId); [${stateName}_commitmentFlag, ${stateName}_0_oldCommitment, ${stateName}_1_oldCommitment] = getInputCommitments( @@ -248,7 +250,7 @@ class BoilerplateGenerator { \n${stateName}_witness_0 = await getMembershipWitness('${contractName}', generalise(${stateName}_0_oldCommitment._id).integer); \n${stateName}_witness_1 = await getMembershipWitness('${contractName}', generalise(${stateName}_1_oldCommitment._id).integer); - \n const tx = await joinCommitments('${contractName}', '${mappingName}', ${isSharedSecret? `sharedSecretKey, sharedPublicKey`: `secretKey, publicKey`}, [${stateVarId.join(' , ')}], [${stateName}_0_oldCommitment, ${stateName}_1_oldCommitment], [${stateName}_witness_0, ${stateName}_witness_1], instance, contractAddr, web3); + \n const tx = await joinCommitments('${contractName}', '${mappingName}', ${isSharedSecret? `sharedSecretKey, sharedPublicKey`: `secretKey, publicKey`}, [${stateVarId.join(' , ')}], [${stateName}_0_oldCommitment, ${stateName}_1_oldCommitment], [${stateName}_witness_0, ${stateName}_witness_1], instance, contractAddr, web3, context); ${stateName}_preimage = await getCommitmentsById(${stateName}_stateVarId); @@ -471,10 +473,14 @@ class BoilerplateGenerator { `\nimport { storeCommitment, getCurrentWholeCommitment, getCommitmentsById, getAllCommitments, getInputCommitments, joinCommitments, splitCommitments, markNullified} from './common/commitment-storage.mjs';`, `\nimport { generateProof } from './common/zokrates.mjs';`, `\nimport { getMembershipWitness, getRoot } from './common/timber.mjs';`, - `\nimport { decompressStarlightKey, compressStarlightKey, encrypt, decrypt, poseidonHash, scalarMult } from './common/number-theory.mjs'; + `\nimport { decompressStarlightKey, compressStarlightKey, encrypt, decrypt, poseidonHash, scalarMult } from './common/number-theory.mjs';`, + `\nimport { KeyManager } from './common/key-management/KeyManager.mjs';`, + `\nimport { autoFundIfNeeded } from './common/gas-funding.mjs';`, + `\nimport logger from './common/logger.mjs'; \n`, `\nconst { generalise } = GN;`, `\nconst db = '/app/orchestration/common/db/preimage.json';`, + `\n// Legacy keyDb path - keys now managed through KeyManager`, `\nconst keyDb = '/app/orchestration/common/db/key.json';\n\n`, ]; }, @@ -705,7 +711,7 @@ sendTransaction = { }, secretKey: ${stateName}_newOwnerPublicKey.integer === ${isSharedSecret ? `sharedPublicKey.integer` : `publicKey.integer`} ? ${isSharedSecret ? `sharedSecretKey` : `secretKey`}: null, isNullified: false, - });` + errorCatch]; + }, SAAS_CONTEXT_PARAM);` + errorCatch]; case 'decrement': value = structProperties ? `{ ${structProperties.map((p, i) => `${p}: ${stateName}_change.integer[${i}]`)} }` : `${stateName}_change`; return [` @@ -724,7 +730,7 @@ sendTransaction = { }, secretKey: ${stateName}_newOwnerPublicKey.integer === ${isSharedSecret ? `sharedPublicKey.integer` : `publicKey.integer`} ? ${isSharedSecret ? `sharedSecretKey` : `secretKey`}: null, isNullified: false, - });`+ errorCatch]; + }, SAAS_CONTEXT_PARAM);`+ errorCatch]; case 'whole': switch (burnedOnly) { case true: @@ -735,6 +741,21 @@ sendTransaction = { return [` \n${reinitialisedOnly ? ' ': `if (${stateName}_commitmentExists) await markNullified(${stateName}_currentCommitment, secretKey.hex(32)); `} + \n// Look up recipient's accountId for proper multi-tenant isolation + \nlet ${stateName}_recipientContext = SAAS_CONTEXT_PARAM; + \nif (SAAS_CONTEXT_PARAM && ${stateName}_newOwnerPublicKey.integer !== ${isSharedSecret ? `sharedPublicKey.integer` : `publicKey.integer`}) { + \n// Commitment is being transferred to a different user + \nconst ${stateName}_recipientAddress = recipient.hex ? recipient.hex(20) : generalise(recipient).hex(20); + \nconst keyManager = KeyManager.getInstance(); + \nconst ${stateName}_recipientAccountId = await keyManager.getAccountIdByEthAddress(${stateName}_recipientAddress); + \nif (${stateName}_recipientAccountId) { + \n${stateName}_recipientContext = { accountId: ${stateName}_recipientAccountId }; + \nlogger.debug(\`Storing commitment for recipient accountId: \${${stateName}_recipientAccountId}\`); + \n} else { + \nlogger.debug(\`Recipient \${${stateName}_recipientAddress} not registered, storing without accountId\`); + \n${stateName}_recipientContext = undefined; + \n} + \n} \n try { \nawait storeCommitment({ hash: ${stateName}_newCommitment, @@ -748,7 +769,7 @@ sendTransaction = { }, secretKey: ${stateName}_newOwnerPublicKey.integer === ${isSharedSecret ? `sharedPublicKey.integer` : `publicKey.integer`} ? ${isSharedSecret ? `sharedSecretKey` : `secretKey`}: null, isNullified: false, - });` + errorCatch]; + }, ${stateName}_recipientContext);` + errorCatch]; } default: throw new TypeError(stateType); @@ -828,7 +849,7 @@ integrationApiServicesBoilerplate = { ` }, preStatements(): string{ - return ` import { startEventFilter, getSiblingPath } from './common/timber.mjs';\nimport fs from "fs";\nimport logger from './common/logger.mjs';\nimport { decrypt } from "./common/number-theory.mjs";\nimport { getAllCommitments, getCommitmentsByState, getBalance, getSharedSecretskeys , getBalanceByState } from "./common/commitment-storage.mjs";\nimport { backupDataRetriever } from "./BackupDataRetriever.mjs";\nimport { backupVariable } from "./BackupVariable.mjs";\nimport web3 from './common/web3.mjs';\n\n + return ` import { startEventFilter, getSiblingPath } from './common/timber.mjs';\nimport fs from "fs";\nimport logger from './common/logger.mjs';\nimport { decrypt } from "./common/number-theory.mjs";\nimport { getAllCommitments, getCommitmentsByState, getBalance, getSharedSecretskeys , getBalanceByState } from "./common/commitment-storage.mjs";\nimport { backupDataRetriever } from "./BackupDataRetriever.mjs";\nimport { backupVariable } from "./BackupVariable.mjs";\nimport web3 from './common/web3.mjs';\nimport { KeyManager } from './common/key-management/KeyManager.mjs';\n\n /** NOTE: this is the api service file, if you need to call any function use the correct url and if Your input contract has two functions, add() and minus(). minus() cannot be called before an initial add(). */ @@ -862,7 +883,8 @@ integrationApiServicesBoilerplate = { return ` export async function service_allCommitments(req, res, next) { try { - const commitments = await getAllCommitments(); + const accountId = req.saasContext?.accountId; + const commitments = await getAllCommitments(accountId); res.send({ commitments }); await sleep(10); } catch (err) { @@ -872,8 +894,8 @@ integrationApiServicesBoilerplate = { } export async function service_getBalance(req, res, next) { try { - - const sum = await getBalance(); + const accountId = req.saasContext?.accountId; + const sum = await getBalance(accountId); res.send( {"totalBalance": sum} ); } catch (error) { console.error("Error in calculation :", error); @@ -884,7 +906,8 @@ integrationApiServicesBoilerplate = { export async function service_getBalanceByState(req, res, next) { try { const { name, mappingKey } = req.body; - const balance = await getBalanceByState(name, mappingKey); + const accountId = req.saasContext?.accountId; + const balance = await getBalanceByState(name, mappingKey, accountId); res.send( {"totalBalance": balance} ); } catch (error) { console.error("Error in calculation :", error); @@ -896,7 +919,8 @@ integrationApiServicesBoilerplate = { export async function service_getCommitmentsByState(req, res, next) { try { const { name, mappingKey } = req.body; - const commitments = await getCommitmentsByState(name, mappingKey); + const accountId = req.saasContext?.accountId; + const commitments = await getCommitmentsByState(name, mappingKey, accountId); res.send({ commitments }); await sleep(10); } catch (err) { @@ -908,7 +932,8 @@ integrationApiServicesBoilerplate = { export async function service_backupData(req, res, next) { try { - await backupDataRetriever(); + SAAS_CONTEXT_HANDLING + await backupDataRetriever(SAAS_CONTEXT_DIRECT); res.send("Complete"); await sleep(10); } catch (err) { @@ -919,7 +944,8 @@ integrationApiServicesBoilerplate = { export async function service_backupVariable(req, res, next) { try { const { name } = req.body; - await backupVariable(name); + SAAS_CONTEXT_HANDLING + await backupVariable(name, SAAS_CONTEXT_PARAM); res.send("Complete"); await sleep(10); } catch (err) { @@ -931,13 +957,202 @@ integrationApiServicesBoilerplate = { try { const { recipientAddress } = req.body; const recipientPubKey = req.body.recipientPubKey || 0 - const SharedKeys = await getSharedSecretskeys(recipientAddress, recipientPubKey ); + SAAS_CONTEXT_HANDLING + const SharedKeys = await getSharedSecretskeys(recipientAddress, recipientPubKey, SAAS_CONTEXT_PARAM); res.send({ SharedKeys }); await sleep(10); } catch (err) { logger.error(err); res.send({ errors: [err.message] }); } + } + export async function service_registerKeys(req, res, next) { + try { + SAAS_CONTEXT_HANDLING + + const keyManager = KeyManager.getInstance(); + let keys = await keyManager.getKeys(SAAS_CONTEXT_PARAM); + + if (keys) { + return res.send({ + success: true, + message: 'Keys already registered', + address: keys.ethPK, + publicKey: keys.publicKey + }); + } + + logger.info('Registering new keys', { accountId: SAAS_CONTEXT_PARAM?.accountId }); + + const utils = await import('zkp-utils'); + const { registerKey } = await import('./common/contract.mjs'); + + const publicKey = await registerKey( + utils.default.randomHex(31), + 'CONTRACT_NAME', + true, + SAAS_CONTEXT_PARAM + ); + + keys = await keyManager.getKeys(SAAS_CONTEXT_PARAM); + + res.send({ + success: true, + message: 'Keys registered successfully', + address: keys.ethPK, + publicKey: keys.publicKey, + zkpPublicKey: publicKey.integer + }); + } catch (err) { + logger.error('Failed to register keys:', err); + res.send({ errors: [err.message] }); + } + } + + export async function service_getAddress(req, res, next) { + try { + SAAS_CONTEXT_HANDLING + + const keyManager = KeyManager.getInstance(); + let keys = await keyManager.getKeys(SAAS_CONTEXT_PARAM); + + if (!keys) { + return res.send({ + success: false, + message: 'No keys found. Please call /registerKeys first.' + }); + } + + res.send({ + address: keys.ethPK, + publicKey: keys.publicKey + }); + } catch (err) { + logger.error(err); + res.send({ errors: [err.message] }); + } + } + + export async function service_mintNFT(req, res, next) { + try { + const { tokenId } = req.body; + SAAS_CONTEXT_HANDLING + + const keyManager = KeyManager.getInstance(); + let keys = await keyManager.getKeys(SAAS_CONTEXT_PARAM); + + if (!keys) { + return res.send({ + success: false, + message: 'No keys found. Please call /registerKeys first.' + }); + } + + const { getContractAddress, getContractInterface } = await import('./common/contract.mjs'); + const erc721Address = await getContractAddress('ERC721'); + const erc721Interface = await getContractInterface('ERC721'); + const Web3 = await import('./common/web3.mjs'); + const web3 = Web3.default.connection(); + const erc721 = new web3.eth.Contract(erc721Interface.abi, erc721Address); + + try { + const owner = await erc721.methods.ownerOf(tokenId).call(); + if (owner && owner !== '0x0000000000000000000000000000000000000000') { + return res.send({ + success: false, + message: \`Token \${tokenId} already exists (owner: \${owner})\` + }); + } + } catch (error) { + // Token doesn't exist - expected + } + + const accounts = await web3.eth.getAccounts(); + const defaultAccount = accounts[0]; + + logger.info(\`Minting token \${tokenId} to \${keys.ethPK}\`); + + const mintTx = await erc721.methods + .mint(keys.ethPK, tokenId) + .send({ from: defaultAccount, gas: 500000 }); + + res.send({ + success: true, + tokenId: tokenId, + owner: keys.ethPK, + txHash: mintTx.transactionHash + }); + } catch (err) { + logger.error('Failed to mint NFT:', err); + res.send({ errors: [err.message] }); + } + } + + export async function service_approveNFT(req, res, next) { + try { + const { tokenId } = req.body; + SAAS_CONTEXT_HANDLING + + const keyManager = KeyManager.getInstance(); + let keys = await keyManager.getKeys(SAAS_CONTEXT_PARAM); + + if (!keys) { + return res.send({ + success: false, + message: 'No keys found. Please call /registerKeys first.' + }); + } + + const { getContractAddress, getContractInterface } = await import('./common/contract.mjs'); + const erc721Address = await getContractAddress('ERC721'); + const erc721Interface = await getContractInterface('ERC721'); + const Web3 = await import('./common/web3.mjs'); + const web3 = Web3.default.connection(); + const erc721 = new web3.eth.Contract(erc721Interface.abi, erc721Address); + + const escrowAddress = await getContractAddress('CONTRACT_NAME'); + + const currentApproval = await erc721.methods.getApproved(tokenId).call(); + + if (currentApproval.toLowerCase() === escrowAddress.toLowerCase()) { + return res.send({ + success: true, + message: 'Token already approved', + tokenId: tokenId, + spender: escrowAddress + }); + } + + logger.info(\`Approving token \${tokenId} for escrow\`); + + const txData = await erc721.methods + .approve(escrowAddress, tokenId) + .encodeABI(); + + const config = await import('config'); + + let txParams = { + from: keys.ethPK, + to: erc721Address, + gas: 500000, + gasPrice: config.default.web3.options.defaultGasPrice, + data: txData, + chainId: await web3.eth.net.getId(), + }; + + const signed = await web3.eth.accounts.signTransaction(txParams, keys.ethSK); + const sendTxn = await web3.eth.sendSignedTransaction(signed.rawTransaction); + + res.send({ + success: true, + tokenId: tokenId, + spender: escrowAddress, + txHash: sendTxn.transactionHash + }); + } catch (err) { + logger.error('Failed to approve NFT:', err); + res.send({ errors: [err.message] }); + } }` @@ -961,7 +1176,7 @@ integrationApiRoutesBoilerplate = { return `router.post('/FUNCTION_NAME', this.serviceMgr.service_FUNCTION_NAME.bind(this.serviceMgr),);` }, commitmentImports(): string { - return `import { service_allCommitments, service_getCommitmentsByState, service_getSharedKeys, service_getBalance, service_getBalanceByState, service_backupData, service_backupVariable,} from "./api_services.mjs";\n`; + return `import { service_allCommitments, service_getCommitmentsByState, service_getSharedKeys, service_getBalance, service_getBalanceByState, service_backupData, service_backupVariable, service_registerKeys, service_getAddress, service_mintNFT, service_approveNFT, } from "./api_services.mjs";\n`; }, commitmentRoutes(): string { return `// commitment getter routes @@ -973,12 +1188,17 @@ integrationApiRoutesBoilerplate = { // backup route router.post("/backupDataRetriever", service_backupData); router.post("/backupVariable", service_backupVariable); + // key management routes + router.post("/registerKeys", service_registerKeys); + router.get("/getAddress", service_getAddress); + router.post("/mintNFT", service_mintNFT); + router.post("/approveNFT", service_approveNFT); `; } }; -zappFilesBoilerplate = () => { - return [ +zappFilesBoilerplate = (multiTenant = false) => { + const baseFiles = [ { readPath: pathPrefix + '/config/default.js', writePath: '/config/default.js', @@ -1070,6 +1290,48 @@ zappFilesBoilerplate = () => { generic: false, }, ]; + +if (multiTenant) { + baseFiles.push( + { + readPath: pathPrefix + '/middleware/saas-context.mjs', + writePath: './orchestration/common/middleware/saas-context.mjs', + generic: false, + }, + { + readPath: pathPrefix + '/key-management/IKeyStorage.mjs', + writePath: './orchestration/common/key-management/IKeyStorage.mjs', + generic: false, + }, + { + readPath: pathPrefix + '/key-management/FileKeyStorage.mjs', + writePath: './orchestration/common/key-management/FileKeyStorage.mjs', + generic: false, + }, + { + readPath: pathPrefix + '/key-management/DatabaseKeyStorage.mjs', + writePath: './orchestration/common/key-management/DatabaseKeyStorage.mjs', + generic: false, + }, + { + readPath: pathPrefix + '/key-management/KeyManager.mjs', + writePath: './orchestration/common/key-management/KeyManager.mjs', + generic: false, + }, + { + readPath: pathPrefix + '/key-management/encryption.mjs', + writePath: './orchestration/common/key-management/encryption.mjs', + generic: false, + }, + { + readPath: pathPrefix + '/key-management/index.mjs', + writePath: './orchestration/common/key-management/index.mjs', + generic: false, + } + ); + } + + return baseFiles; } } diff --git a/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts b/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts index 056a9868..f4b18a7b 100644 --- a/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts +++ b/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts @@ -38,7 +38,7 @@ const stateVariableIds = (node: any) => { privateStateName.includes('msg') ) { stateVarIds.push( - `\nconst ${privateStateName}_stateVarId_key = generalise(config.web3.options.defaultAccount); // emulates msg.sender`, + `\nconst ${privateStateName}_stateVarId_key = generalise(keys.ethPK); // emulates msg.sender`, ); } stateVarIds.push( @@ -501,9 +501,6 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { if (node.stateMutability !== 'view') { lines.push(`let BackupData = [];`); } - if (node.msgSenderParam) - lines.push(` - \nconst msgSender = generalise(config.web3.options.defaultAccount);`); if (node.msgValueParam) lines.push(` \nconst msgValue = 1;`); @@ -573,7 +570,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { return { signature: [ `${functionSig} - \n async ${node.name}(${params} ${states}) {`, + \n async ${node.name}(${params} ${states}, context) {`, `\n return { ${txReturns} ${publicReturns}}; \n} \n}`, @@ -585,7 +582,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { return { signature: [ ` - \n async ${node.name}(${params} ${states}) {`, + \n async ${node.name}(${params} ${states}, context) {`, `\n const bool = true; \n return { ${txReturns} ${rtnparams}, ${publicReturns} }; \n} \n}`, @@ -596,7 +593,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { return { signature: [ ` ${functionSig} - \n async ${node.name}(${params} ${states}) {`, + \n async ${node.name}(${params} ${states}, context) {`, `\nreturn { ${txReturns} ${rtnparams}, ${publicReturns}}; \n} \n}`, @@ -647,6 +644,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { `${Orchestrationbp.initialiseKeys.postStatements( node.contractName, states[0], + node.msgSenderParam, ) }`, ], }; @@ -1054,18 +1052,20 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { return { statements: [ `${returnsCall} + \n\n// Ensure user has enough funds + \nawait autoFundIfNeeded(keys.ethPK, '0.5', '1'); \n\n// Send transaction to the blockchain: \nconst txData = await instance.methods .${node.functionName}(${lines.length > 0 ? `${lines},`: ``} {customInputs: [${returnInputs}], newNullifiers: ${params[0][0]} commitmentRoot:${params[0][1]} checkNullifiers: ${params[0][3]} newCommitments: ${params[0][2]} cipherText:${params[0][4]} encKeys: ${params[0][5]}}, proof, BackupData).encodeABI(); \n let txParams = { - from: config.web3.options.defaultAccount, + from: keys.ethPK, to: contractAddr, gas: config.web3.options.defaultGas, gasPrice: config.web3.options.defaultGasPrice, data: txData, chainId: await web3.eth.net.getId(), }; - \n const key = config.web3.key; + \n const key = keys.ethSK; \n const signed = await web3.eth.accounts.signTransaction(txParams, key); \n const sendTxn = await web3.eth.sendSignedTransaction(signed.rawTransaction); \n let tx = await instance.getPastEvents("NewLeaves", {fromBlock: sendTxn?.blockNumber || 0, toBlock: sendTxn?.blockNumber || 'latest'}); @@ -1156,17 +1156,19 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { return { statements: [ `${returnsCallPublic} + \n\n// Ensure user has enough funds + \nawait autoFundIfNeeded(keys.ethPK, '0.05', '0.5'); \n\n// Send transaction to the blockchain: \nconst txData = await instance.methods.${node.functionName}(${lines}).encodeABI(); \nlet txParams = { - from: config.web3.options.defaultAccount, + from: keys.ethPK, to: contractAddr, gas: config.web3.options.defaultGas, gasPrice: config.web3.options.defaultGasPrice, data: txData, chainId: await web3.eth.net.getId(), }; - \nconst key = config.web3.key; + \nconst key = keys.ethSK; \nconst signed = await web3.eth.accounts.signTransaction(txParams, key); \nconst tx = await web3.eth.sendSignedTransaction(signed.rawTransaction); \nconst encEvent = {}; diff --git a/src/codeGenerators/common.ts b/src/codeGenerators/common.ts index 0bcfcafc..ceb5c609 100644 --- a/src/codeGenerators/common.ts +++ b/src/codeGenerators/common.ts @@ -20,6 +20,7 @@ export const collectImportFiles = ( context: string, contextDirPath?: string, fileName: string = '', + visited: any = new Set(), ) => { const lines = file.split('\n'); let ImportStatementList: string[]; @@ -97,8 +98,16 @@ export const collectImportFiles = ( } const absPath = path.resolve(contextDirPath, p); const relPath = path.relative('.', absPath); + + if (visited.has(relPath)) { + continue; + } + const exists = fs.existsSync(relPath); if (!exists) continue; + + visited.add(relPath); + const f = fs.readFileSync(relPath, 'utf8'); const n = path.basename(absPath, path.extname(absPath)); const shortRelPath = path.relative(path.resolve(fileURLToPath(import.meta.url), '../../../'), absPath); @@ -129,12 +138,12 @@ export const collectImportFiles = ( file: f, }); - localFiles = localFiles.concat(collectImportFiles(f, context, path.dirname(relPath), context === 'contract' ? n : '')); + localFiles = localFiles.concat(collectImportFiles(f, context, path.dirname(relPath), context === 'contract' ? n : '', visited)); } // remove duplicate files after recursion: const uniqueLocalFiles = localFiles.filter((obj, i, self) => { - return self.indexOf(obj) === i; + return self.findIndex(item => item.filepath === obj.filepath) === i; }); return uniqueLocalFiles; diff --git a/src/codeGenerators/orchestration/files/toOrchestration.ts b/src/codeGenerators/orchestration/files/toOrchestration.ts index 7a3ac14c..e1c25a56 100644 --- a/src/codeGenerators/orchestration/files/toOrchestration.ts +++ b/src/codeGenerators/orchestration/files/toOrchestration.ts @@ -213,6 +213,20 @@ const prepareIntegrationApiServices = (node: any) => { fnboilerplate = fnboilerplate.replace(/_RESPONSE_/g, returnParams + publicReturns); + // Handle SaaS context placeholders based on multi-tenant flag + fnboilerplate = fnboilerplate.replace( + /SAAS_CONTEXT_HANDLING/g, + node.multiTenant + ? `// Pass context for multi-tenant support (available via saasContextMiddleware) + const context = req.saasContext;` + : `// Single-tenant mode - no context needed`, + ); + + fnboilerplate = fnboilerplate.replace( + /SAAS_CONTEXT_PARAM/g, + node.multiTenant ? `context` : `undefined`, + ); + // replace function imports at top of file const fnimport = ` import { ${(fn.name).charAt(0).toUpperCase() + fn.name.slice(1)}Manager } from './${fn.name}.mjs' ;` @@ -222,7 +236,29 @@ const prepareIntegrationApiServices = (node: any) => { }); // add linting and config const preprefix = `/* eslint-disable prettier/prettier, camelcase, prefer-const, no-unused-vars */ \nimport config from 'config';\nimport assert from 'assert';\n`; - outputApiServiceFile = `${preprefix}\n${outputApiServiceFile}}\n ${genericApiServiceFile.commitments()}\n`; + + // Handle SaaS context in commitments functions + let commitmentsCode = genericApiServiceFile.commitments(); + commitmentsCode = commitmentsCode.replace( + /SAAS_CONTEXT_HANDLING/g, + node.multiTenant + ? `// Pass context for multi-tenant support + const context = req.saasContext;` + : `// Single-tenant mode - no context needed`, + ); + commitmentsCode = commitmentsCode.replace( + /SAAS_CONTEXT_PARAM/g, + node.multiTenant ? `context` : `undefined`, + ); + commitmentsCode = commitmentsCode.replace( + /SAAS_CONTEXT_DIRECT/g, + node.multiTenant ? `context` : `undefined`, + ); + commitmentsCode = commitmentsCode.replace( + /CONTRACT_NAME/g, + node.contractName, + ); + outputApiServiceFile = `${preprefix}\n${outputApiServiceFile}}\n ${commitmentsCode}\n`; return outputApiServiceFile; }; const prepareIntegrationApiRoutes = (node: any) => { @@ -831,6 +867,7 @@ const prepareBackupDataRetriever = (node: any) => { getContractInstance, getContractAddress, } from "./common/contract.mjs"; + import { KeyManager } from "./common/key-management/KeyManager.mjs"; import Web3 from "./common/web3.mjs"; import { @@ -849,7 +886,7 @@ const prepareBackupDataRetriever = (node: any) => { const { MONGO_URL, COMMITMENTS_DB, COMMITMENTS_COLLECTION } = config; - export async function backupDataRetriever() { + export async function backupDataRetriever(context) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); @@ -873,11 +910,13 @@ const prepareBackupDataRetriever = (node: any) => { const backDataEvent = await instance.getPastEvents('EncryptedBackupData',{fromBlock: 0, toBlock: 'latest'} ); - const keys = JSON.parse( - fs.readFileSync(keyDb, "utf-8", (err) => { - console.log(err); - }) - ); + // Use KeyManager for key retrieval + const keyManager = KeyManager.getInstance(); + const keys = await keyManager.getKeys(context); + + if (!keys) { + throw new Error('No keys found. Please register keys first.'); + } const secretKey = generalise(keys.secretKey); const publicKey = generalise(keys.publicKey); const sharedPublicKey = generalise(keys.sharedPublicKey); @@ -1040,13 +1079,19 @@ export default function fileGenerator(node: any) { .flatMap(fileGenerator)); case 'File': + let fileContent = node.nodes.map(codeGenerator).join(''); + + fileContent = fileContent.replace( + /SAAS_CONTEXT_PARAM/g, + node.multiTenant ? `context` : `undefined`, + ); return [ { filepath: path.join( `./orchestration`, `${node.fileName}${node.fileExtension}`, ), - file: node.nodes.map(codeGenerator).join(''), + file: fileContent, }, ]; // case 'ImportStatementList': diff --git a/src/transformers/toOrchestration.ts b/src/transformers/toOrchestration.ts index 4e4df63e..07d0e19f 100644 --- a/src/transformers/toOrchestration.ts +++ b/src/transformers/toOrchestration.ts @@ -24,7 +24,8 @@ export default function toOrchestration(ast: any, options: any) { snarkVerificationRequired: true, newCommitmentsRequired: true, nullifiersRequired: true, - circuitAST:options.circuitAST + circuitAST:options.circuitAST, + multiTenant: options.multiTenant }; logger.debug('Transforming the .zol AST to a .mjs AST...'); @@ -71,7 +72,7 @@ export default function toOrchestration(ast: any, options: any) { `Saving backend files to the zApp output directory ${options.outputDirPath}...`, ); // TODO merge this process with above - const zappFilesBP = Orchestrationbp.zappFilesBoilerplate(); + const zappFilesBP = Orchestrationbp.zappFilesBoilerplate(options.multiTenant); if (!(zappFilesBP instanceof Array)) throw new Error('Boilerplate files not read correctly!'); let fileObj: any; // we go through the below process in the codeGenerator for other files @@ -99,6 +100,30 @@ export default function toOrchestration(ast: any, options: any) { await eventListener.start()` : ` `, ); + + // Handle SaaS middleware based on multi-tenant flag + file = file.replace( + /SAAS_MIDDLEWARE_IMPORT/g, + options.multiTenant + ? `import { saasContextMiddleware } from './common/middleware/saas-context.mjs';` + : ``, + ); + + file = file.replace( + /SAAS_MIDDLEWARE_USAGE/g, + options.multiTenant + ? `// Add SaaS context middleware for multi-tenant support +// This middleware parses the x-saas-context header and attaches req.saasContext +// If no header is present, the app operates in single-tenant mode (backward compatible) +app.use(saasContextMiddleware);` + : ``, + ); + + // Replace multi-tenant mode configuration + file = file.replace( + /MULTI_TENANT_MODE/g, + options.multiTenant ? `true` : `false`, + ); } const dir = pathjs.dirname(filepath); logger.debug(`About to save to ${filepath}...`); diff --git a/src/transformers/visitors/common.ts b/src/transformers/visitors/common.ts index df32c6c7..ec935a47 100644 --- a/src/transformers/visitors/common.ts +++ b/src/transformers/visitors/common.ts @@ -22,6 +22,7 @@ export const initialiseOrchestrationBoilerplateNodes = (fnIndicator: FunctionDef newNodes.InitialiseKeysNode = buildNode('InitialiseKeys', { contractName, onChainKeyRegistry: fnIndicator.onChainKeyRegistry, + msgSenderParam: fnIndicator.msgSenderParam, }); if (fnIndicator.oldCommitmentAccessRequired || fnIndicator.internalFunctionoldCommitmentAccessRequired) newNodes.initialisePreimageNode = buildNode('InitialisePreimage'); diff --git a/src/transformers/visitors/toOrchestrationVisitor.ts b/src/transformers/visitors/toOrchestrationVisitor.ts index 12a44985..7bb301e4 100644 --- a/src/transformers/visitors/toOrchestrationVisitor.ts +++ b/src/transformers/visitors/toOrchestrationVisitor.ts @@ -376,6 +376,7 @@ const visitor = { const newNode = buildNode('File', { fileName: 'test', fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('IntegrationTestBoilerplate', { contractName, @@ -393,10 +394,12 @@ const visitor = { newNode = buildNode('File', { fileName: 'api_services', fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('IntegrationApiServicesBoilerplate', { contractName, contractImports: state.contractImports, + multiTenant: state.multiTenant, }), ], }); @@ -404,6 +407,7 @@ const visitor = { newNode = buildNode('File', { fileName: 'api_routes', fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('IntegrationApiRoutesBoilerplate', { contractName, @@ -415,6 +419,7 @@ const visitor = { newNode = buildNode('File', { fileName: 'BackupDataRetriever', fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('BackupDataRetrieverBoilerplate', { contractName, @@ -426,6 +431,7 @@ const visitor = { newNode = buildNode('File', { fileName: 'BackupVariable', fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('BackupVariableBoilerplate', { contractName, @@ -438,6 +444,7 @@ const visitor = { newNode = buildNode('File', { fileName: 'encrypted-data-listener', fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('IntegrationEncryptedListenerBoilerplate', { contractName, @@ -498,6 +505,7 @@ const visitor = { const newNode = buildNode('File', { fileName: fnName, // the name of this function fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('Imports'), buildNode('FunctionDefinition', { name: node.name, contractName, stateMutability: node.stateMutability}), @@ -545,6 +553,7 @@ const visitor = { const newNode = buildNode('File', { fileName: fnName, fileExtension: '.mjs', + multiTenant: state.multiTenant, nodes: [ buildNode('Imports'), buildNode('FunctionDefinition', { name: node.name, contractName, stateMutability: node.stateMutability }), diff --git a/src/traverse/Binding.ts b/src/traverse/Binding.ts index 536c3c56..d71ddb61 100644 --- a/src/traverse/Binding.ts +++ b/src/traverse/Binding.ts @@ -599,17 +599,7 @@ export class VariableBinding extends Binding { } } } - // mapping[key] = msg.sender is owned by msg.sender => look for mapping[key] = 0 - // OR owner is some value (admin = address) => look for admin = 0 - if ( - ownerNode.name === 'msg' && - ownerNode.mappingOwnershipType === 'value' - ) { - // the owner is represented by the mapping value - we look through the modifyingPaths for 0 - this.searchModifyingPathsForZero(); - } else if (ownerBinding && ownerBinding instanceof VariableBinding) { - ownerBinding.searchModifyingPathsForZero(); - } + this.searchModifyingPathsForZero(); if (this.reinitialisable && !this.isBurned) throw new SyntaxUsageError( `The state ${this.name} has been marked as reinitialisable but we can't find anywhere to burn a commitment ready for reinitialisation.`, diff --git a/src/traverse/Indicator.ts b/src/traverse/Indicator.ts index db20579d..009fc13f 100644 --- a/src/traverse/Indicator.ts +++ b/src/traverse/Indicator.ts @@ -80,6 +80,8 @@ export class FunctionDefinitionIndicator extends ContractDefinitionIndicator { internalFunctionModifiesSecretState?: boolean; internalFunctionoldCommitmentAccessRequired?: boolean; onChainKeyRegistry?: boolean; + msgSenderParam?: boolean; + msgValueParam?: boolean; constructor(scope: Scope) { super(); @@ -145,7 +147,7 @@ export class FunctionDefinitionIndicator extends ContractDefinitionIndicator { // if we have a indicator which is NOT burned, then we do need new commitments if ( stateVarIndicator.isSecret && - (!stateVarIndicator.isBurned || stateVarIndicator.newCommitmentsRequired) + (!stateVarIndicator.isBurned && stateVarIndicator.newCommitmentsRequired) ) { burnedOnly = false; break; diff --git a/src/types/orchestration-types.ts b/src/types/orchestration-types.ts index 30cd3818..c8eca101 100644 --- a/src/types/orchestration-types.ts +++ b/src/types/orchestration-types.ts @@ -9,12 +9,13 @@ import { buildBoilerplateNode } from '../boilerplate/orchestration/javascript/no export default function buildNode(nodeType: string, fields: any = {}): any { switch (nodeType) { case 'File': { - const { fileName, fileExtension = '.mjs', nodes = [] } = fields; + const { fileName, fileExtension = '.mjs', nodes = [], multiTenant } = fields; return { nodeType, fileName, fileExtension, nodes, + multiTenant, }; } case 'Imports': { diff --git a/test/contracts/user-friendly-tests/NFT_Escrow.zol b/test/contracts/user-friendly-tests/NFT_Escrow.zol index 7f80ea03..eb851ee4 100644 --- a/test/contracts/user-friendly-tests/NFT_Escrow.zol +++ b/test/contracts/user-friendly-tests/NFT_Escrow.zol @@ -6,6 +6,7 @@ import "./Escrow-imports/IERC721.sol"; contract NFT_Escrow { + secret mapping(uint256 => address) public isActivated; secret mapping(uint256 => address) public tokenOwners; // mapped-to by a tokenId secret mapping(address => address) public approvals; IERC721 public erc721; @@ -21,9 +22,11 @@ contract NFT_Escrow { } function transfer(secret address recipient, secret uint256 tokenId) public { + require(isActivated[tokenId] == msg.sender, "NFT_Escrow: token should be activated"); require(tokenOwners[tokenId] == msg.sender); require(recipient != address(0), "NFT_Escrow: transfer to the zero address"); tokenOwners[tokenId] = recipient; + isActivated[tokenId] = recipient; } function approve(secret address approvedAddress) public { @@ -36,6 +39,7 @@ contract NFT_Escrow { require(recipient != address(0), "NFT_Escrow: transfer to the zero address"); require(sender != address(0), "NFT_Escrow: transfer from the zero address"); tokenOwners[tokenId] = recipient; + isActivated[tokenId] = recipient; } function withdraw(uint256 tokenId) public { @@ -44,4 +48,14 @@ contract NFT_Escrow { require(success, "ERC721 transfer failed"); tokenOwners[tokenId] = address(0); } + + function activate(secret uint256 tokenId) public { + require(tokenOwners[tokenId] == msg.sender, "NFT_Escrow: Sender doesn't have access"); + reinitialisable isActivated[tokenId] = msg.sender; + } + + function deactivate(secret uint256 tokenId) public { + require(tokenOwners[tokenId] == msg.sender, "NFT_Escrow: Sender doesn't have access"); + isActivated[tokenId] = address(0); + } }