diff --git a/scripts/nft-escrow-e2e.sh b/scripts/nft-escrow-e2e.sh new file mode 100755 index 00000000..f8cf2877 --- /dev/null +++ b/scripts/nft-escrow-e2e.sh @@ -0,0 +1,377 @@ +#!/bin/bash + +set -e # Exit on error + +# Configuration +BASE_URL="http://localhost:3000" +ACCOUNT_ID="user-alice" +ZAPP_DIR="zapps/NFT_Escrow_DomainParams" + +# Use random token IDs to avoid conflicts with previous tests +TOKEN_ID_1=$((100 + RANDOM % 900)) +TOKEN_ID_2=$((1000 + RANDOM % 9000)) + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "==========================================" +echo "NFT_Escrow_DomainParams - Multi-NFT Test" +echo "==========================================" +echo "" +echo "Using accountId: $ACCOUNT_ID" +echo "Testing namespace isolation..." +echo "" + +# Helper function to print success +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +# Helper function to print error +print_error() { + echo -e "${RED}✗${NC} $1" +} + +# Helper function to print info +print_info() { + echo -e "${YELLOW}ℹ${NC} $1" +} + +# Step 1: Verify bytecode is present +echo "Step 1: Verifying bytecode in ERC721.json..." +if [ -f "$ZAPP_DIR/build/contracts/ERC721.json" ]; then + if cat "$ZAPP_DIR/build/contracts/ERC721.json" | jq -e '.bytecode' > /dev/null 2>&1; then + BYTECODE_LENGTH=$(cat "$ZAPP_DIR/build/contracts/ERC721.json" | jq -r '.bytecode' | wc -c) + print_success "Bytecode found! Length: $BYTECODE_LENGTH characters" + else + print_error "Bytecode not found in ERC721.json" + exit 1 + fi +else + print_error "ERC721.json not found at $ZAPP_DIR/build/contracts/ERC721.json" + exit 1 +fi +echo "" + +# Step 2: Register keys for user +echo "Step 2: Registering keys for user..." +REGISTER_RESPONSE=$(curl -s -X POST "$BASE_URL/registerKeys" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d '{}') +print_info "Response: $REGISTER_RESPONSE" + +# Extract user address +USER_ADDRESS=$(echo "$REGISTER_RESPONSE" | jq -r '.address // empty') +if [ -z "$USER_ADDRESS" ] || [ "$USER_ADDRESS" == "null" ]; then + print_error "Failed to register keys" + exit 1 +fi +print_success "Keys registered. User address: $USER_ADDRESS" +echo "" + +# Step 3: Deploy first ERC721 contract (CryptoKitties) +echo "Step 3: Deploying first NFT contract (CryptoKitties)..." +DEPLOY1_RESPONSE=$(curl -s -X POST "$BASE_URL/deployNFT" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d '{"name": "CryptoKitties", "symbol": "CK"}') + +CONTRACT1_ADDRESS=$(echo "$DEPLOY1_RESPONSE" | jq -r '.contractAddress // empty') +if [ -z "$CONTRACT1_ADDRESS" ] || [ "$CONTRACT1_ADDRESS" == "null" ]; then + print_error "Failed to deploy CryptoKitties contract" + echo "Response: $DEPLOY1_RESPONSE" + exit 1 +fi +print_success "CryptoKitties deployed at: $CONTRACT1_ADDRESS" +echo "" + +# Step 4: Deploy second ERC721 contract (CryptoPunks) +echo "Step 4: Deploying second NFT contract (CryptoPunks)..." +DEPLOY2_RESPONSE=$(curl -s -X POST "$BASE_URL/deployNFT" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d '{"name": "CryptoPunks", "symbol": "CP"}') + +CONTRACT2_ADDRESS=$(echo "$DEPLOY2_RESPONSE" | jq -r '.contractAddress // empty') +if [ -z "$CONTRACT2_ADDRESS" ] || [ "$CONTRACT2_ADDRESS" == "null" ]; then + print_error "Failed to deploy CryptoPunks contract" + echo "Response: $DEPLOY2_RESPONSE" + exit 1 +fi +print_success "CryptoPunks deployed at: $CONTRACT2_ADDRESS" +echo "" + +# Step 5: Verify contracts are different +echo "Step 5: Verifying contracts are different..." +if [ "$CONTRACT1_ADDRESS" == "$CONTRACT2_ADDRESS" ]; then + print_error "Both contracts have the same address!" + exit 1 +fi +print_success "Contracts have different addresses" +echo "" + +# Step 6: Mint NFT #1 from CryptoKitties contract +echo "Step 6: Minting NFT #$TOKEN_ID_1 from CryptoKitties contract ($CONTRACT1_ADDRESS)..." +MINT1_RESPONSE=$(curl -s -X POST "$BASE_URL/mintNFT" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"tokenId\": \"$TOKEN_ID_1\", \"nftContract\": \"$CONTRACT1_ADDRESS\"}") + +MINT1_SUCCESS=$(echo "$MINT1_RESPONSE" | jq -r '.success // false') +if [ "$MINT1_SUCCESS" != "true" ]; then + print_error "Failed to mint NFT #$TOKEN_ID_1 from CryptoKitties" + echo "Response: $MINT1_RESPONSE" + exit 1 +fi +print_success "Minted NFT #$TOKEN_ID_1 from CryptoKitties contract" +echo "" + +# Step 7: Mint NFT #2 from CryptoPunks contract +echo "Step 7: Minting NFT #$TOKEN_ID_2 from CryptoPunks contract ($CONTRACT2_ADDRESS)..." +MINT2_RESPONSE=$(curl -s -X POST "$BASE_URL/mintNFT" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"tokenId\": \"$TOKEN_ID_2\", \"nftContract\": \"$CONTRACT2_ADDRESS\"}") + +MINT2_SUCCESS=$(echo "$MINT2_RESPONSE" | jq -r '.success // false') +if [ "$MINT2_SUCCESS" != "true" ]; then + print_error "Failed to mint NFT #$TOKEN_ID_2 from CryptoPunks" + echo "Response: $MINT2_RESPONSE" + exit 1 +fi +print_success "Minted NFT #$TOKEN_ID_2 from CryptoPunks contract" +echo "" + +# Step 8: Approve NFT #1 from CryptoKitties for escrow +echo "Step 8: Approving NFT #$TOKEN_ID_1 from CryptoKitties for escrow..." +APPROVE1_RESPONSE=$(curl -s -X POST "$BASE_URL/approveNFT" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"tokenId\": \"$TOKEN_ID_1\", \"nftContract\": \"$CONTRACT1_ADDRESS\"}") + +APPROVE1_SUCCESS=$(echo "$APPROVE1_RESPONSE" | jq -r '.success // false') +if [ "$APPROVE1_SUCCESS" != "true" ]; then + print_error "Failed to approve NFT #$TOKEN_ID_1 from CryptoKitties" + echo "Response: $APPROVE1_RESPONSE" + exit 1 +fi +print_success "Approved NFT #$TOKEN_ID_1 from CryptoKitties for escrow" +echo "" + +# Step 9: Approve NFT #2 from CryptoPunks for escrow +echo "Step 9: Approving NFT #$TOKEN_ID_2 from CryptoPunks for escrow..." +APPROVE2_RESPONSE=$(curl -s -X POST "$BASE_URL/approveNFT" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"tokenId\": \"$TOKEN_ID_2\", \"nftContract\": \"$CONTRACT2_ADDRESS\"}") + +APPROVE2_SUCCESS=$(echo "$APPROVE2_RESPONSE" | jq -r '.success // false') +if [ "$APPROVE2_SUCCESS" != "true" ]; then + print_error "Failed to approve NFT #$TOKEN_ID_2 from CryptoPunks" + echo "Response: $APPROVE2_RESPONSE" + exit 1 +fi +print_success "Approved NFT #$TOKEN_ID_2 from CryptoPunks for escrow" +echo "" + +# Step 10: Deposit NFT #1 with CryptoKitties as domain parameter +echo "Step 10: Depositing NFT #$TOKEN_ID_1 with nftContract=$CONTRACT1_ADDRESS (CryptoKitties)..." +DEPOSIT1_RESPONSE=$(curl -s -X POST "$BASE_URL/deposit" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"nftContract\": \"$CONTRACT1_ADDRESS\", \"tokenId\": \"$TOKEN_ID_1\"}") + +print_info "Deposit response: $DEPOSIT1_RESPONSE" + +# Check if deposit was successful (look for proof or success indicator) +if echo "$DEPOSIT1_RESPONSE" | jq -e '.proof' > /dev/null 2>&1; then + print_success "NFT #$TOKEN_ID_1 deposited successfully with CryptoKitties domain parameter" +elif echo "$DEPOSIT1_RESPONSE" | jq -e '.errors' > /dev/null 2>&1; then + ERRORS=$(echo "$DEPOSIT1_RESPONSE" | jq -r '.errors[]') + print_error "Failed to deposit NFT #$TOKEN_ID_1: $ERRORS" + exit 1 +else + print_success "NFT #$TOKEN_ID_1 deposit transaction submitted" +fi +echo "" + +# Step 11: Deposit NFT #2 with CryptoPunks as domain parameter +echo "Step 11: Depositing NFT #$TOKEN_ID_2 with nftContract=$CONTRACT2_ADDRESS (CryptoPunks)..." +DEPOSIT2_RESPONSE=$(curl -s -X POST "$BASE_URL/deposit" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"nftContract\": \"$CONTRACT2_ADDRESS\", \"tokenId\": \"$TOKEN_ID_2\"}") + +print_info "Deposit response: $DEPOSIT2_RESPONSE" + +# Check if deposit was successful +if echo "$DEPOSIT2_RESPONSE" | jq -e '.proof' > /dev/null 2>&1; then + print_success "NFT #$TOKEN_ID_2 deposited successfully with CryptoPunks domain parameter" +elif echo "$DEPOSIT2_RESPONSE" | jq -e '.errors' > /dev/null 2>&1; then + ERRORS=$(echo "$DEPOSIT2_RESPONSE" | jq -r '.errors[]') + print_error "Failed to deposit NFT #$TOKEN_ID_2: $ERRORS" + exit 1 +else + print_success "NFT #$TOKEN_ID_2 deposit transaction submitted" +fi +echo "" + +# Step 12: Verify commitments for CryptoKitties domain +echo "Step 12: Fetching commitments for CryptoKitties domain..." +sleep 2 # Wait for commitments to be indexed +COMMITMENTS1_RESPONSE=$(curl -s -X POST "$BASE_URL/getCommitmentsByVariableName" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"name\": \"tokenOwners\", \"domainParameters\": {\"nftContract\": \"$CONTRACT1_ADDRESS\"}}") + +COMMITMENTS1_COUNT=$(echo "$COMMITMENTS1_RESPONSE" | jq '.commitments | length') +print_info "CryptoKitties domain commitments: $COMMITMENTS1_COUNT" +if [ "$COMMITMENTS1_COUNT" -gt 0 ]; then + print_success "Found commitments for CryptoKitties domain" +else + print_error "No commitments found for CryptoKitties domain" +fi +echo "" + +# Step 13: Verify commitments for CryptoPunks domain +echo "Step 13: Fetching commitments for CryptoPunks domain..." +COMMITMENTS2_RESPONSE=$(curl -s -X POST "$BASE_URL/getCommitmentsByVariableName" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"name\": \"tokenOwners\", \"domainParameters\": {\"nftContract\": \"$CONTRACT2_ADDRESS\"}}") + +COMMITMENTS2_COUNT=$(echo "$COMMITMENTS2_RESPONSE" | jq '.commitments | length') +print_info "CryptoPunks domain commitments: $COMMITMENTS2_COUNT" +if [ "$COMMITMENTS2_COUNT" -gt 0 ]; then + print_success "Found commitments for CryptoPunks domain" +else + print_error "No commitments found for CryptoPunks domain" +fi +echo "" + +# Step 14: Verify domain isolation after deposits +echo "Step 14: Verifying domain parameter isolation after deposits..." +print_info "CryptoKitties domain ($CONTRACT1_ADDRESS): $COMMITMENTS1_COUNT commitments" +print_info "CryptoPunks domain ($CONTRACT2_ADDRESS): $COMMITMENTS2_COUNT commitments" + +if [ "$COMMITMENTS1_COUNT" -gt 0 ] && [ "$COMMITMENTS2_COUNT" -gt 0 ]; then + print_success "Domain parameter isolation confirmed! Each nftContract has separate state" +else + print_error "Domain isolation verification incomplete" +fi +echo "" + +# Step 15: Register second user (Bob) for transfers +echo "Step 15: Registering second user (Bob) for transfers..." +REGISTER_BOB_RESPONSE=$(curl -s -X POST "$BASE_URL/registerKeys" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"user-bob\"}" \ + -d '{}') + +BOB_ADDRESS=$(echo "$REGISTER_BOB_RESPONSE" | jq -r '.address') +print_info "Response: $REGISTER_BOB_RESPONSE" +if [ "$BOB_ADDRESS" != "null" ] && [ -n "$BOB_ADDRESS" ]; then + print_success "Bob's keys registered. Address: $BOB_ADDRESS" +else + print_error "Failed to register Bob's keys" + exit 1 +fi +echo "" + +# Step 16: Transfer CryptoKitties NFT from Alice to Bob +echo "Step 16: Transferring NFT #$TOKEN_ID_1 from Alice to Bob (CryptoKitties domain)..." +TRANSFER1_RESPONSE=$(curl -s -X POST "$BASE_URL/transfer" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"tokenId\": $TOKEN_ID_1, \"recipient\": \"$BOB_ADDRESS\", \"nftContract\": \"$CONTRACT1_ADDRESS\"}") + +print_info "Transfer response: $TRANSFER1_RESPONSE" +if echo "$TRANSFER1_RESPONSE" | jq -e '.tx' > /dev/null 2>&1; then + print_success "NFT #$TOKEN_ID_1 transfer transaction submitted (CryptoKitties)" +else + print_error "Failed to transfer NFT #$TOKEN_ID_1 (CryptoKitties)" +fi +echo "" + +# Step 17: Transfer CryptoPunks NFT from Alice to Bob +echo "Step 17: Transferring NFT #$TOKEN_ID_2 from Alice to Bob (CryptoPunks domain)..." +TRANSFER2_RESPONSE=$(curl -s -X POST "$BASE_URL/transfer" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"tokenId\": $TOKEN_ID_2, \"recipient\": \"$BOB_ADDRESS\", \"nftContract\": \"$CONTRACT2_ADDRESS\"}") + +print_info "Transfer response: $TRANSFER2_RESPONSE" +if echo "$TRANSFER2_RESPONSE" | jq -e '.tx' > /dev/null 2>&1; then + print_success "NFT #$TOKEN_ID_2 transfer transaction submitted (CryptoPunks)" +else + print_error "Failed to transfer NFT #$TOKEN_ID_2 (CryptoPunks)" +fi +echo "" + +# Step 18: Verify Alice's commitments after transfers (should be nullified) +echo "Step 18: Verifying Alice's commitments after transfers..." +sleep 2 # Wait for commitments to be updated +ALICE_COMMITMENTS1_RESPONSE=$(curl -s -X POST "$BASE_URL/getCommitmentsByVariableName" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"name\": \"tokenOwners\", \"domainParameters\": {\"nftContract\": \"$CONTRACT1_ADDRESS\"}}") + +ALICE_COMMITMENTS1_COUNT=$(echo "$ALICE_COMMITMENTS1_RESPONSE" | jq '[.commitments[] | select(.isNullified == false)] | length') +print_info "Alice's active CryptoKitties commitments: $ALICE_COMMITMENTS1_COUNT" + +ALICE_COMMITMENTS2_RESPONSE=$(curl -s -X POST "$BASE_URL/getCommitmentsByVariableName" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"$ACCOUNT_ID\"}" \ + -d "{\"name\": \"tokenOwners\", \"domainParameters\": {\"nftContract\": \"$CONTRACT2_ADDRESS\"}}") + +ALICE_COMMITMENTS2_COUNT=$(echo "$ALICE_COMMITMENTS2_RESPONSE" | jq '[.commitments[] | select(.isNullified == false)] | length') +print_info "Alice's active CryptoPunks commitments: $ALICE_COMMITMENTS2_COUNT" + +if [ "$ALICE_COMMITMENTS1_COUNT" -eq 0 ] && [ "$ALICE_COMMITMENTS2_COUNT" -eq 0 ]; then + print_success "Alice's commitments correctly nullified after transfers" +else + print_error "Alice still has active commitments after transfers" +fi +echo "" + +# Step 19: Verify Bob's commitments after transfers +echo "Step 19: Verifying Bob's commitments after transfers..." +BOB_COMMITMENTS1_RESPONSE=$(curl -s -X POST "$BASE_URL/getCommitmentsByVariableName" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"user-bob\"}" \ + -d "{\"name\": \"tokenOwners\", \"domainParameters\": {\"nftContract\": \"$CONTRACT1_ADDRESS\"}}") + +BOB_COMMITMENTS1_COUNT=$(echo "$BOB_COMMITMENTS1_RESPONSE" | jq '[.commitments[] | select(.isNullified == false)] | length') +print_info "Bob's active CryptoKitties commitments: $BOB_COMMITMENTS1_COUNT" + +BOB_COMMITMENTS2_RESPONSE=$(curl -s -X POST "$BASE_URL/getCommitmentsByVariableName" \ + -H "Content-Type: application/json" \ + -H "x-saas-context: {\"accountId\": \"user-bob\"}" \ + -d "{\"name\": \"tokenOwners\", \"domainParameters\": {\"nftContract\": \"$CONTRACT2_ADDRESS\"}}") + +BOB_COMMITMENTS2_COUNT=$(echo "$BOB_COMMITMENTS2_RESPONSE" | jq '[.commitments[] | select(.isNullified == false)] | length') +print_info "Bob's active CryptoPunks commitments: $BOB_COMMITMENTS2_COUNT" + +if [ "$BOB_COMMITMENTS1_COUNT" -eq 1 ] && [ "$BOB_COMMITMENTS2_COUNT" -eq 1 ]; then + print_success "Bob received commitments for both NFTs correctly" +else + print_error "Bob's commitments not correct after transfers" +fi +echo "" + +# Step 20: Final domain isolation verification +echo "Step 20: Final multi nft verification..." +print_info "CryptoKitties domain ($CONTRACT1_ADDRESS): Bob has $BOB_COMMITMENTS1_COUNT commitments" +print_info "CryptoPunks domain ($CONTRACT2_ADDRESS): Bob has $BOB_COMMITMENTS2_COUNT commitments" + +if [ "$BOB_COMMITMENTS1_COUNT" -eq 1 ] && [ "$BOB_COMMITMENTS2_COUNT" -eq 1 ]; then + print_success "Namespace isolation maintained through transfers!" +else + print_error "Namespace isolation verification incomplete after transfers" +fi +echo "" + + diff --git a/src/boilerplate/circuit/zokrates/nodes/BoilerplateGenerator.ts b/src/boilerplate/circuit/zokrates/nodes/BoilerplateGenerator.ts index a0896749..ad6588e2 100644 --- a/src/boilerplate/circuit/zokrates/nodes/BoilerplateGenerator.ts +++ b/src/boilerplate/circuit/zokrates/nodes/BoilerplateGenerator.ts @@ -377,10 +377,15 @@ class BoilerplateGenerator { encryption = () => ({}); - mapping = (bpSection) => ({ - mappingName: this.mappingName, - mappingKeyName: bpSection === 'postStatements' ? this.mappingKeyName : bpSection === 'parameters' ? this.mappingKeyName.split('.')[0] : this.mappingKeyName.replace('.', 'dot'), - }); + mapping = (bpSection) => { + // Get perParameters from the binding's node + const perParameters = this.indicators?.binding?.node?.perParameters || this.indicators?.node?.perParameters || []; + return { + mappingName: this.mappingName, + mappingKeyName: bpSection === 'postStatements' ? this.mappingKeyName : bpSection === 'parameters' ? this.mappingKeyName.split('.')[0] : this.mappingKeyName.replace('.', 'dot'), + ...(perParameters.length > 0 && { perParameters }), + }; + }; /** Partitioned states need boilerplate for an incrementation/decrementation, because it's so weird and different from `a = a - b`. Whole states inherit directly from the AST, so don't need boilerplate here. */ incrementation = (extraParams) => { diff --git a/src/boilerplate/circuit/zokrates/raw/BoilerplateGenerator.ts b/src/boilerplate/circuit/zokrates/raw/BoilerplateGenerator.ts index f0227350..65e352a3 100644 --- a/src/boilerplate/circuit/zokrates/raw/BoilerplateGenerator.ts +++ b/src/boilerplate/circuit/zokrates/raw/BoilerplateGenerator.ts @@ -495,26 +495,77 @@ class BoilerplateGenerator { ]; }, - parameters({ mappingKeyName: k, mappingKeyTypeName: t }): string[] { - if (t === 'local') return []; - return [ - `private ${t ? t : 'field'} ${k}`, // must be a field, in case we need to do arithmetic on it. - ]; + /** + * Generate circuit parameters for mapping + * Includes domain parameters if present + */ + parameters({ mappingKeyName: k, mappingKeyTypeName: t, perParameters = [] }): string[] { + const params: string[] = []; + + // Add domain parameters as private inputs + for (const domainParam of perParameters) { + params.push(`private field ${domainParam.name}`); + } + + // Add mapping key parameter + if (t !== 'local') { + params.push(`private ${t ? t : 'field'} ${k}`); + } + + return params; }, - preStatements({ id: mappingId, mappingName: m }): string[] { - return [ + /** + * Generate pre-statements for mapping + * Includes domain parameter hashing if present + */ + preStatements({ id: mappingId, mappingName: m, perParameters = [] }): string[] { + const statements: string[] = [ ` // We need to hard-code the mappingId's of mappings into the circuit: field ${m}_mappingId = ${mappingId};`, ]; + + // Generate chained MiMC hashing for domain parameters + if (perParameters.length > 0) { + let currentHash = `${m}_mappingId`; + + for (let i = 0; i < perParameters.length; i++) { + const domainParam = perParameters[i]; + const nextHashVar = `${m}_perHash_${i}`; + + statements.push( + ` + // Chain domain parameter: ${domainParam.name} + field ${nextHashVar} = mimc2([${currentHash}, ${domainParam.name}]);`, + ); + + currentHash = nextHashVar; + } + + statements.push( + ` + // Final domain-chained hash + field ${m}_domainChainedId = ${currentHash};`, + ); + } + + return statements; }, - postStatements({ name: x, mappingName: m, mappingKeyName: k }): string[] { - // const x = `${m}_${k}`; + /** + * Generate post-statements for mapping + * Calculates final stateVarId with domain parameter support + */ + postStatements({ name: x, mappingName: m, mappingKeyName: k, perParameters = [] }): string[] { + // Use chained hash if domain parameters exist, otherwise use mappingId + const baseId = perParameters.length > 0 + ? `${m}_domainChainedId` + : `${m}_mappingId`; + return [ ` - field ${x}_stateVarId_field = mimc2([${m}_mappingId, ${k}]);`, + field ${x}_stateVarId_field = mimc2([${baseId}, ${k}]);`, ]; }, }; diff --git a/src/boilerplate/common/commitment-storage.mjs b/src/boilerplate/common/commitment-storage.mjs index 74fdf39f..3c205640 100644 --- a/src/boilerplate/common/commitment-storage.mjs +++ b/src/boilerplate/common/commitment-storage.mjs @@ -35,11 +35,23 @@ export function formatCommitment (commitment) { preimage.value = generalise(commitment.preimage.value).all ? generalise(commitment.preimage.value).all.integer : generalise(commitment.preimage.value).integer + + // Format domain parameters if they exist + const domainParameters = commitment.domainParameters + ? Object.fromEntries( + Object.entries(commitment.domainParameters).map(([key, value]) => [ + key, + generalise(value).integer + ]) + ) + : null; + data = { _id: commitment.hash.hex(32), name: commitment.name, source: commitment.source, mappingKey: commitment.mappingKey ? commitment.mappingKey : null, + domainParameters, secretKey: commitment.secretKey ? commitment.secretKey.hex(32) : null, preimage, isNullified: commitment.isNullified, @@ -47,7 +59,9 @@ export function formatCommitment (commitment) { } logger.debug(`Storing commitment ${data._id}`) } catch (error) { - console.error('Error --->', error) + console.error('Error formatting commitment --->', error) + console.error('Commitment object:', JSON.stringify(commitment, null, 2)) + throw error } return data } @@ -58,9 +72,20 @@ 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) - return persistCommitment(data) +export async function storeCommitment (commitment, context) { + const data = formatCommitment(commitment, context) + if (!data) { + console.error('formatCommitment returned undefined/null data') + throw new Error('Failed to format commitment') + } + try { + const result = await persistCommitment(data) + logger.debug(`Successfully persisted commitment ${data._id}`) + return result + } catch (error) { + console.error('Error persisting commitment:', error) + throw error + } } // function to retrieve commitment with a specified stateVarId @@ -86,11 +111,19 @@ export async function getCurrentWholeCommitment(id) { } // function to retrieve commitment with a specified stateName -export async function getCommitmentsByState(name, mappingKey = null) { +export async function getCommitmentsByState(name, mappingKey = null, domainParameters = null) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); const query = { name: name }; if (mappingKey) query['mappingKey'] = generalise(mappingKey).integer; + + // Add domain parameter filters if provided + if (domainParameters) { + for (const [key, value] of Object.entries(domainParameters)) { + query[`domainParameters.${key}`] = generalise(value).integer; + } + } + const commitments = await db .collection(COMMITMENTS_COLLECTION) .find(query) @@ -139,11 +172,19 @@ export async function getBalance() { return sumOfValues; } -export async function getBalanceByState(name, mappingKey = null) { +export async function getBalanceByState(name, mappingKey = null, domainParameters = null) { const connection = await mongo.connection(MONGO_URL); const db = connection.db(COMMITMENTS_DB); const query = { name: name }; if (mappingKey) query['mappingKey'] = generalise(mappingKey).integer; + + // Add domain parameter filters if provided + if (domainParameters) { + for (const [key, value] of Object.entries(domainParameters)) { + query[`domainParameters.${key}`] = generalise(value).integer; + } + } + const commitments = await db .collection(COMMITMENTS_COLLECTION) .find(query) diff --git a/src/boilerplate/common/contract.mjs b/src/boilerplate/common/contract.mjs index 173929f4..a7e5ca96 100644 --- a/src/boilerplate/common/contract.mjs +++ b/src/boilerplate/common/contract.mjs @@ -80,7 +80,13 @@ export async function getContractInstance(contractName, deployedAddress) { export async function getContractBytecode(contractName) { const contractInterface = await getContractInterface(contractName); - return contractInterface.evm.bytecode.object; + // Support both Hardhat format (bytecode) and Truffle/Solc format (evm.bytecode.object) + if (contractInterface.bytecode) { + return contractInterface.bytecode; + } else if (contractInterface.evm?.bytecode?.object) { + return contractInterface.evm.bytecode.object; + } + throw new Error(`Bytecode not found for contract ${contractName}`); } export async function deploy( diff --git a/src/boilerplate/common/migrations/metadata.js b/src/boilerplate/common/migrations/metadata.js index 6d80496b..b07c31ec 100644 --- a/src/boilerplate/common/migrations/metadata.js +++ b/src/boilerplate/common/migrations/metadata.js @@ -48,11 +48,15 @@ function saveMetadata ( // console.log("hardhatArtifactPath: ", hardhatArtifactPath); const compilationData = fs.readFileSync(hardhatArtifactPath, 'utf-8') - const abi = JSON.parse(compilationData).abi - const contractNameFromHardhat = JSON.parse(compilationData).contractName + const compiledContract = JSON.parse(compilationData) + const abi = compiledContract.abi + const contractNameFromHardhat = compiledContract.contractName + const bytecode = compiledContract.bytecode deployedMetadata.abi = abi deployedMetadata.contractName = contractNameFromHardhat + // Save bytecode for runtime deployment (needed for deployNFT endpoint) + deployedMetadata.bytecode = bytecode deployedPositionMetadata.address = contractDeployedAddress deployedPositionMetadata.blockNumber = blockNumber deployedPositionMetadata.transactionHash = transactionHash diff --git a/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts b/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts index 0097a485..f1241124 100644 --- a/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts +++ b/src/boilerplate/orchestration/javascript/nodes/boilerplate-generator.ts @@ -11,12 +11,15 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { switch (nodeType) { case 'InitialisePreimage': { const { privateStateName, id, accessedOnly = false, indicator = {} } = fields; + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { privateStateName, stateVarId: id, accessedOnly, mappingKey: indicator.isMapping ? indicator.referencedKeyName || indicator.keyPath.node.name : null, mappingName: indicator.isMapping ? indicator.node?.name : null, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, structProperties: indicator.isStruct ? Object.keys(indicator.structProperties) : null, }; } @@ -29,6 +32,8 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { accessedOnly, indicator = {}, } = fields; + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { increment, stateVarId: id, @@ -38,6 +43,7 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { structProperties: indicator.isStruct ? Object.keys(indicator.structProperties) : null, mappingKey: indicator.isMapping ? indicator.referencedKeyName || indicator.keyPath.node.name : null, mappingName: indicator.isMapping ? indicator.node?.name : null, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, nullifierRequired: indicator.isNullified, reinitialisedOnly, accessedOnly, @@ -56,6 +62,8 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { } case 'WritePreimage': { const { id, increment, burnedOnly, reinitialisedOnly, indicator = {} } = fields + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { increment, stateVarId: id, @@ -65,6 +73,7 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { structProperties: indicator.isStruct ? indicator.referencingPaths[0]?.getStructDeclaration()?.members.map(m => m.name) : null, mappingKey: indicator.isMapping ? indicator.referencedKeyName || indicator.keyPath.node.name : null, mappingName: indicator.isMapping ? indicator.node?.name : null, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, nullifierRequired: indicator.isNullified, burnedOnly, reinitialisedOnly, @@ -132,6 +141,8 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { } case 'CalculateCommitment': { const { id, increment, privateStateName, indicator = {} } = fields; + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { privateStateName, stateVarId: id, @@ -141,6 +152,7 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { isPartitioned: indicator.isPartitioned, nullifierRequired: indicator.isNullified, structProperties: indicator.isStruct ? indicator.referencingPaths[0]?.getStructDeclaration()?.members.map(m => m.name) : null, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, isOwned: indicator.isOwned, mappingOwnershipType: indicator.mappingOwnershipType, owner: indicator.isOwned @@ -163,6 +175,8 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { localMappingKey, } = fields; const structProperties = !indicator.isStruct ? null : indicator.isAccessed ? indicator.referencingPaths[0]?.getStructDeclaration()?.members.map(m => m.name) : Object.keys(indicator.structProperties); + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { privateStateName, stateVarId: id, @@ -174,6 +188,7 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { increment, structProperties, isMapping: indicator.isMapping, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, isWhole: indicator.isWhole, isPartitioned: indicator.isPartitioned, isOwned: indicator.isOwned, @@ -192,12 +207,15 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { case 'EncryptBackupPreimage': { const { id, increment, privateStateName, indicator = {} } = fields; + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { privateStateName, stateVarId: id, increment, mappingKey: indicator.isMapping ? indicator.referencedKeyName || indicator.keyPath.node.name : null, mappingName: indicator.isMapping ? indicator.node?.name : null, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, isWhole: indicator.isWhole, isPartitioned: indicator.isPartitioned, nullifierRequired: indicator.isNullified, @@ -235,12 +253,15 @@ export function buildPrivateStateNode(nodeType: string, fields: any = {}): any { case 'buildBoilerplateReciever': { const { id, increment, privateStateName, indicator = {} } = fields; + // For MappingKey indicators, access binding through container + const binding = indicator.binding || indicator.container?.binding; return { privateStateName, stateVarId: id, increment, mappingKey: indicator.isMapping ? indicator.referencedKeyName || indicator.keyPath.node.name : null, mappingName: indicator.isMapping ? indicator.node?.name : null, + perParameters: indicator.isMapping && binding?.node?.perParameters ? binding.node.perParameters : undefined, isWhole: indicator.isWhole, isPartitioned: indicator.isPartitioned, structProperties: indicator.isStruct ? indicator.referencingPaths[0]?.getStructDeclaration()?.members.map(m => m.name) : null, @@ -283,11 +304,12 @@ export function buildBoilerplateNode(nodeType: string, fields: any = {}): any { }; } case 'ReadPreimage': { - const { contractName, privateStates = {} } = fields; + const { contractName, privateStates = {}, inputParameters = [] } = fields; return { nodeType, privateStates, contractName, + inputParameters, }; } case 'WritePreimage': { diff --git a/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts b/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts index 21d89ba1..e1e58b08 100644 --- a/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts +++ b/src/boilerplate/orchestration/javascript/raw/boilerplate-generator.ts @@ -677,7 +677,8 @@ sendTransaction = { burnedOnly, reinitialisedOnly, structProperties, - isConstructor + isConstructor, + perParameters, }): string[] { let value; const errorCatch = `\n console.log("Added commitment", newCommitment.hex(32)); @@ -688,6 +689,12 @@ sendTransaction = { ); } }`; + + // Generate domain parameters object if perParameters exist + const domainParamsCode = perParameters && perParameters.length > 0 + ? `domainParameters: { ${perParameters.map(p => `${p.name}: ${p.name}_init`).join(', ')} },\n ` + : ''; + switch (stateType) { case 'increment': value = structProperties ? `{ ${structProperties.map((p, i) => `${p}: ${stateName}_newCommitmentValue.integer[${i}]`)} }` : `${stateName}_newCommitmentValue`; @@ -696,7 +703,7 @@ sendTransaction = { hash: ${stateName}_newCommitment, name: '${mappingName}', mappingKey: ${mappingKey === `` ? `null` : `${mappingKey}`}, - preimage: { + ${domainParamsCode}preimage: { \tstateVarId: generalise(${stateName}_stateVarId), \tvalue: ${value}, \tsalt: ${stateName}_newSalt, @@ -715,7 +722,7 @@ sendTransaction = { hash: ${stateName}_2_newCommitment, name: '${mappingName}', mappingKey: ${mappingKey === `` ? `null` : `${mappingKey}`}, - preimage: { + ${domainParamsCode}preimage: { \tstateVarId: generalise(${stateName}_stateVarId), \tvalue: ${value}, \tsalt: ${stateName}_2_newSalt, @@ -739,7 +746,7 @@ sendTransaction = { hash: ${stateName}_newCommitment, name: '${mappingName}', mappingKey: ${mappingKey === `` ? `null` : `${mappingKey}`}, - preimage: { + ${domainParamsCode}preimage: { \tstateVarId: generalise(${stateName}_stateVarId), \tvalue: ${value}, \tsalt: ${stateName}_newSalt, @@ -882,20 +889,20 @@ integrationApiServicesBoilerplate = { export async function service_getBalanceByState(req, res, next) { try { - const { name, mappingKey } = req.body; - const balance = await getBalanceByState(name, mappingKey); + const { name, mappingKey, domainParameters } = req.body; + const balance = await getBalanceByState(name, mappingKey, domainParameters); res.send( {"totalBalance": balance} ); } catch (error) { console.error("Error in calculation :", error); res.status(500).send({ error: err.message }); } } - - + + export async function service_getCommitmentsByState(req, res, next) { try { - const { name, mappingKey } = req.body; - const commitments = await getCommitmentsByState(name, mappingKey); + const { name, mappingKey, domainParameters } = req.body; + const commitments = await getCommitmentsByState(name, mappingKey, domainParameters); res.send({ commitments }); await sleep(10); } catch (err) { @@ -965,9 +972,9 @@ integrationApiRoutesBoilerplate = { commitmentRoutes(): string { return `// commitment getter routes router.get("/getAllCommitments", service_allCommitments); - router.get("/getCommitmentsByVariableName", service_getCommitmentsByState); + router.post("/getCommitmentsByVariableName", service_getCommitmentsByState); router.get("/getBalance", service_getBalance); - router.get("/getBalanceByState", service_getBalanceByState); + router.post("/getBalanceByState", service_getBalanceByState); router.post("/getSharedKeys", service_getSharedKeys); // backup route router.post("/backupDataRetriever", service_backupData); diff --git a/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts b/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts index 04d70e84..e3aa018b 100644 --- a/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts +++ b/src/boilerplate/orchestration/javascript/raw/toOrchestration.ts @@ -3,9 +3,20 @@ import OrchestrationBP from './boilerplate-generator.js'; +/** + * Generates stateVarId calculation code with support for domain parameters + * + * For mappings without domain parameters: + * stateVarId = mimc2([mappingId, key]) + * + * For mappings with domain parameters (chained MiMC hashing): + * perHash = mimc2([mimc2([...mimc2([mappingId, domain1]), domain2...]), domainN]) + * stateVarId = mimc2([perHash, key]) + */ const stateVariableIds = (node: any) => { const {privateStateName, stateNode} = node; const stateVarIds: string[] = []; + // state variable ids // if not a mapping, use singular unique id (if mapping, stateVarId is an array) if (!stateNode.stateVarId[1]) { @@ -14,35 +25,76 @@ const stateVariableIds = (node: any) => { ); } else { // if is a mapping... + const mappingId = stateNode.stateVarId[0]; + const mappingKey = stateNode.stateVarId[1]; + const domainParameters = stateNode.perParameters || []; + stateVarIds.push( - `\nlet ${privateStateName}_stateVarIdInit = ${stateNode.stateVarId[0]};`, + `\nlet ${privateStateName}_stateVarIdInit = ${mappingId};`, ); + + // Handle domain parameters with chained MiMC hashing + if (domainParameters.length > 0) { + // Extract and hash each domain parameter in sequence + let currentHash = `generalise(${privateStateName}_stateVarIdInit).bigInt`; + + for (let i = 0; i < domainParameters.length; i++) { + const domainParam = domainParameters[i]; + const domainVarName = `${privateStateName}_domain_${domainParam.name}`; + + // Generate code to extract domain parameter + stateVarIds.push( + `\nconst ${domainVarName} = ${domainParam.name};`, + ); + + // Chain the MiMC hash: mimc2([currentHash, domainParam]) + const nextHashVar = `${privateStateName}_perHash_${i}`; + stateVarIds.push( + `\nconst ${nextHashVar} = generalise(utils.mimcHash([${currentHash}, ${domainVarName}.bigInt], 'ALT_BN_254')).bigInt;`, + ); + + currentHash = nextHashVar; + } + + // Store the final chained hash for use in final stateVarId calculation + stateVarIds.push( + `\nlet ${privateStateName}_stateVarIdInit_chained = ${currentHash};`, + ); + } + // ... and the mapping key is not msg.sender, but is a parameter if ( - privateStateName.includes(stateNode.stateVarId[1].replaceAll('.', 'dot')) && - stateNode.stateVarId[1] !== 'msg' + privateStateName.includes(mappingKey.replaceAll('.', 'dot')) && + mappingKey !== 'msg' ) { - if (+stateNode.stateVarId[1] || stateNode.stateVarId[1] === '0') { + if (+mappingKey || mappingKey === '0') { stateVarIds.push( - `\nconst ${privateStateName}_stateVarId_key = generalise(${stateNode.stateVarId[1]});`, + `\nconst ${privateStateName}_stateVarId_key = generalise(${mappingKey});`, ); } else { stateVarIds.push( - `\nconst ${privateStateName}_stateVarId_key = ${stateNode.stateVarId[1]};`, + `\nconst ${privateStateName}_stateVarId_key = ${mappingKey};`, ); } } // ... and the mapping key is msg, and the caller of the fn has the msg key if ( - stateNode.stateVarId[1] === 'msg' && + mappingKey === 'msg' && privateStateName.includes('msg') ) { stateVarIds.push( `\nconst ${privateStateName}_stateVarId_key = generalise(config.web3.options.defaultAccount); // emulates msg.sender`, ); } + + // Calculate final stateVarId + // If domain parameters exist, use the chained hash; otherwise use mappingId + const baseId = domainParameters.length > 0 + ? `${privateStateName}_stateVarIdInit_chained` + : `generalise(${privateStateName}_stateVarIdInit).bigInt`; + stateVarIds.push( - `\nlet ${privateStateName}_stateVarId = generalise(utils.mimcHash([generalise(${privateStateName}_stateVarIdInit).bigInt, ${privateStateName}_stateVarId_key.bigInt], 'ALT_BN_254')).hex(32);`, + `\nlet ${privateStateName}_stateVarId = generalise(utils.mimcHash([${baseId}, ${privateStateName}_stateVarId_key.bigInt], 'ALT_BN_254')).hex(32);`, ); } return stateVarIds; @@ -166,8 +218,9 @@ export const generateProofBoilerplate = (node: any) => { const stateVarIdLines = !stateNode.localMappingKey && stateNode.isMapping && !(node.parameters.includes(stateNode.stateVarId[1])) && !(node.parameters.includes(stateNode.stateVarId[2])) && !msgSenderParamAndMappingKey && !msgValueParamAndMappingKey && !constantMappingKey ? [`\n\t\t\t\t\t\t\t\t${stateName}_stateVarId_key.integer,`] - : []; + : []; // we add any extra params the circuit needs + // Preserve the order from node.parameters (which matches the circuit parameter order) node.parameters .filter((para: string) => { if (privateStateNames.includes(para)) return false; @@ -176,15 +229,9 @@ export const generateProofBoilerplate = (node: any) => { }) .forEach((para: string) => { const transformed = transformToIntegerAccess(para); - if (para === 'msgValue') { - parameters.unshift(`\t${transformed},`); - } else if (para === 'msgSender') { - parameters.unshift(`\t${transformed},`); - } else { - parameters.push(`\t${transformed},`); - } + parameters.push(`\t${transformed},`); }); - + // then we build boilerplate code per state switch (stateNode.isWhole) { case true: @@ -333,14 +380,28 @@ export const preimageBoilerPlate = (node: any) => { preimageParams.push(`\t${privateStateName}: 0,`); // ownership (PK in commitment) + // For reinitialisable states (transfers), we need to use the assignment RHS as the new owner + // not the require statement owner (which is the old owner) const newOwner = stateNode.isOwned ? stateNode.owner : null; let newOwnerStatment: string; switch (newOwner) { case null: - if(stateNode.isSharedSecret) - newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? sharedPublicKey : ${privateStateName}_newOwnerPublicKey;`; - else - newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? publicKey : ${privateStateName}_newOwnerPublicKey;`; + // Check if there's a 'recipient' parameter in the function (including secret parameters) + const hasRecipientParam = node.inputParameters && node.inputParameters.includes('recipient'); + + if (hasRecipientParam && stateNode.reinitialisable) { + // For transfer functions with a recipient parameter, look up the recipient's public key + newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? generalise(await instance.methods.zkpPublicKeys(recipient.hex ? recipient.hex(20) : generalise(recipient).hex(20)).call()) : ${privateStateName}_newOwnerPublicKey; + \nif (_${privateStateName}_newOwnerPublicKey === 0 && ${privateStateName}_newOwnerPublicKey.integer === 0) { + \nconsole.log('WARNING: Public key for recipient address not found - using your public key'); + \n${privateStateName}_newOwnerPublicKey = ${stateNode.isSharedSecret ? 'sharedPublicKey' : 'publicKey'}; + \n} + \n${privateStateName}_newOwnerPublicKey = generalise(${privateStateName}_newOwnerPublicKey);`; + } else if(stateNode.isSharedSecret) { + newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? sharedPublicKey : ${privateStateName}_newOwnerPublicKey;`; + } else { + newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? publicKey : ${privateStateName}_newOwnerPublicKey;`; + } break; case 'msg': if (privateStateName.includes('msg')) { @@ -350,7 +411,19 @@ export const preimageBoilerPlate = (node: any) => { newOwnerStatment = `generalise(await instance.methods.zkpPublicKeys(${stateNode.stateVarId[1]}.hex(20)).call()); // address should be registered`; } else if (stateNode.mappingOwnershipType === 'value') { if (stateNode.reinitialisable){ - newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? publicKey : ${privateStateName}_newOwnerPublicKey;`; + // For reinitialisable states (transfers), look up the new owner's public key from the contract + // Check if there's a parameter that could be the new owner (like 'recipient') + const hasRecipientParam = node.inputParameters && node.inputParameters.includes('recipient'); + if (hasRecipientParam) { + newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? generalise(await instance.methods.zkpPublicKeys(recipient.hex ? recipient.hex(20) : generalise(recipient).hex(20)).call()) : ${privateStateName}_newOwnerPublicKey; + \nif (_${privateStateName}_newOwnerPublicKey === 0 && ${privateStateName}_newOwnerPublicKey.integer === 0) { + \nconsole.log('WARNING: Public key for recipient address not found - using your public key'); + \n${privateStateName}_newOwnerPublicKey = ${stateNode.isSharedSecret ? 'sharedPublicKey' : 'publicKey'}; + \n} + \n${privateStateName}_newOwnerPublicKey = generalise(${privateStateName}_newOwnerPublicKey);`; + } else { + newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? ${stateNode.isSharedSecret ? 'sharedPublicKey' : 'publicKey'} : ${privateStateName}_newOwnerPublicKey;`; + } } else { // TODO test below // if the private state is an address (as here) its still in eth form - we need to convert @@ -374,7 +447,14 @@ export const preimageBoilerPlate = (node: any) => { if (!stateNode.ownerIsSecret && !stateNode.ownerIsParam) { newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? generalise(await instance.methods.zkpPublicKeys(await instance.methods.${newOwner}().call()).call()) : ${privateStateName}_newOwnerPublicKey;`; } else if (stateNode.ownerIsParam && newOwner) { - newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? ${newOwner} : ${privateStateName}_newOwnerPublicKey;`; + // Owner is a parameter - need to look up the public key from the contract + // The parameter could be an address (like 'recipient') or already a public key + newOwnerStatment = `_${privateStateName}_newOwnerPublicKey === 0 ? generalise(await instance.methods.zkpPublicKeys(${newOwner}.hex ? ${newOwner}.hex(20) : generalise(${newOwner}).hex(20)).call()) : ${privateStateName}_newOwnerPublicKey; + \nif (_${privateStateName}_newOwnerPublicKey === 0 && ${privateStateName}_newOwnerPublicKey.integer === 0) { + \nconsole.log('WARNING: Public key for ${newOwner} address not found - using your public key'); + \n${privateStateName}_newOwnerPublicKey = ${stateNode.isSharedSecret ? 'sharedPublicKey' : 'publicKey'}; + \n} + \n${privateStateName}_newOwnerPublicKey = generalise(${privateStateName}_newOwnerPublicKey);`; } else { // is secret - we just use the users to avoid revealing the secret owner if(stateNode.isSharedSecret) @@ -678,6 +758,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { structProperties: stateNode.structProperties, isConstructor: node.isConstructor, reinitialisedOnly: false, + perParameters: stateNode.perParameters, })); break; @@ -696,6 +777,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { structProperties: stateNode.structProperties, isConstructor: node.isConstructor, reinitialisedOnly: stateNode.reinitialisedOnly, + perParameters: stateNode.perParameters, })); break; @@ -716,6 +798,7 @@ export const OrchestrationCodeBoilerPlate: any = (node: any) => { reinitialisedOnly: stateNode.reinitialisedOnly, structProperties: stateNode.structProperties, isConstructor: node.isConstructor, + perParameters: stateNode.perParameters, })); } } diff --git a/src/codeGenerators/orchestration/files/toOrchestration.ts b/src/codeGenerators/orchestration/files/toOrchestration.ts index 41b476dd..4e8ee491 100644 --- a/src/codeGenerators/orchestration/files/toOrchestration.ts +++ b/src/codeGenerators/orchestration/files/toOrchestration.ts @@ -422,18 +422,18 @@ const prepareMigrationsFile = (file: localFile, node: any) => { } }); // we collect any imported contracts which must be migrated - if (node.contractImports && constructorParamsIncludesAddr) { + if (node.contractImports) { node.contractImports.forEach((importObj: any) => { // read each imported contract if(!fs.existsSync(`./contracts/${importObj.absolutePath}`)){ logger.warn(`Please Make Sure you Deploy all the imports before testing the zApp.`); return; - } + } const importedContract = fs.readFileSync( `./contracts/${importObj.absolutePath}`, 'utf8', ); - + let importedContractName = path.basename( importObj.absolutePath, path.extname(importObj.absolutePath), @@ -494,56 +494,60 @@ const prepareMigrationsFile = (file: localFile, node: any) => { ) \n await erc1155.waitForDeployment() \n erc1155Address = await erc1155.getAddress() \n - console.log('ERC1155 deployed to:', erc1155Address) \n + console.log('ERC1155 deployed to:', erc1155Address) \n blockNumber = await hre.ethers.provider.getBlockNumber(); \n deployTx = await erc1155.deploymentTransaction().wait() \n saveMetadata(erc1155Address, 'ERC1155', "/Escrow-imports", chainId, blockNumber, deployTx.hash) \n \n`; - break; + break; } - } + } } - if ( - importedContractName === 'ERC20' || - importedContractName === 'ERC721' || importedContractName === 'ERC1155' - ) { - // for each address in the shield contract constructor... - constructorAddrParams.forEach(name => { - if ( - name - .toLowerCase() - .includes(importedContractName.substring(1).toLowerCase()) || - importedContractName - .substring(1) - .toLowerCase() - .includes(name.toLowerCase()) - ) { - // if that address is of the current importedContractName, we add it to the migration arguments - const index = constructorParamNames.indexOf(name); - constructorParamNames[index] = `${importedContractName.toLowerCase()}Address`; - } - }); - } else { + // Only update constructor params if we have address params in the constructor + if (constructorParamsIncludesAddr) { + if ( + importedContractName === 'ERC20' || + importedContractName === 'ERC721' || importedContractName === 'ERC1155' + ) { // for each address in the shield contract constructor... - constructorAddrParams.forEach(name => { - if ( - name - .toLowerCase() - .includes(importedContractName.substring(1).toLowerCase()) || - importedContractName - .substring(1) - .toLowerCase() - .includes(name.toLowerCase()) - ) { - // if that address is of the current importedContractName, we add it to the migration arguments - const index = constructorParamNames.indexOf(name); - constructorParamNames[index] = `${importedContractName}.address`; - } - }); + constructorAddrParams.forEach(name => { + if ( + name + .toLowerCase() + .includes(importedContractName.substring(1).toLowerCase()) || + importedContractName + .substring(1) + .toLowerCase() + .includes(name.toLowerCase()) + ) { + // if that address is of the current importedContractName, we add it to the migration arguments + const index = constructorParamNames.indexOf(name); + constructorParamNames[index] = `${importedContractName.toLowerCase()}Address`; + } + }); + } else { + // for each address in the shield contract constructor... + constructorAddrParams.forEach(name => { + if ( + name + .toLowerCase() + .includes(importedContractName.substring(1).toLowerCase()) || + importedContractName + .substring(1) + .toLowerCase() + .includes(name.toLowerCase()) + ) { + // if that address is of the current importedContractName, we add it to the migration arguments + const index = constructorParamNames.indexOf(name); + constructorParamNames[index] = `${importedContractName}.address`; + } + }); + } } } }); - } else if(constructorParamsIncludesAddr) { + } + if(constructorParamsIncludesAddr) { // for each address in the shield contract constructor... constructorAddrParams.forEach(name => { // we have an address input which is likely not a another contract diff --git a/src/parse/redecorate.ts b/src/parse/redecorate.ts index 1dee0fd2..a9748575 100644 --- a/src/parse/redecorate.ts +++ b/src/parse/redecorate.ts @@ -14,9 +14,15 @@ export class ToRedecorate { decorator: string; charStart: number; added?: boolean; + perParameters?: Array<{type: string, name: string}>; + perFunctionParam?: boolean; + paramType?: string; + paramName?: string; } const errorCheckVisitor = (thisPath: any, decoratorObj: any) => { + // skip if node doesn't have src property + if (!thisPath.node || !thisPath.node.src) return; // extract the char number const srcStart = thisPath.node.src.split(':')[0]; // if it matches the one we removed, throw error @@ -34,6 +40,83 @@ function transformation1(oldAST: any, toRedecorate: ToRedecorate[]) { // HACK: ordinarily the 2nd parameter `state` is an object. toRedecorate is an array (special kind of object). Not ideal, but it works. traverseNodesFastVisitor(oldAST, explode(redecorateVisitor), toRedecorate); + // Handle per(...) mapping decorators that weren't matched by the visitor + // This can happen when the charStart doesn't match the src property + for (const decorator of toRedecorate) { + if (decorator.added || decorator.decorator !== 'per' || decorator.perFunctionParam) continue; + + // Handle per(...) mapping decorators + // Try to find a VariableDeclaration node that matches + const findPerNode = (node: any): any => { + if (node && node.nodeType === 'VariableDeclaration' && node.src && node.stateVariable) { + const srcStart = node.src.split(':')[0]; + if (decorator.charStart === Number(srcStart)) { + return node; + } + } + if (node && typeof node === 'object') { + for (const key in node) { + if (Array.isArray(node[key])) { + for (const item of node[key]) { + const result = findPerNode(item); + if (result) return result; + } + } else if (typeof node[key] === 'object') { + const result = findPerNode(node[key]); + if (result) return result; + } + } + } + return null; + }; + + const perNode = findPerNode(oldAST); + if (perNode) { + perNode.perParameters = decorator.perParameters || []; + decorator.added = true; + } + } + + // Handle per function parameters by finding the parameter nodes and setting isPer flag + // We need to track which parameters have been marked to avoid marking the same parameter twice + const markedParams = new Set(); + + for (const decorator of toRedecorate) { + if (!decorator.perFunctionParam || !decorator.paramName) continue; + + // Find all function definitions and their parameters + const findAndMarkPerParams = (node: any): void => { + if (node && node.nodeType === 'FunctionDefinition' && node.parameters && node.parameters.parameters) { + // Look for the parameter with the matching name in this function + for (const param of node.parameters.parameters) { + if (param.nodeType === 'VariableDeclaration' && param.name === decorator.paramName && !param.stateVariable) { + // Create a unique key for this parameter + const paramKey = `${node.id}_${param.id}`; + if (!markedParams.has(paramKey)) { + param.isPer = true; + markedParams.add(paramKey); + return; // Found and marked, move to next decorator + } + } + } + } + + if (node && typeof node === 'object') { + for (const key in node) { + if (Array.isArray(node[key])) { + for (const item of node[key]) { + findAndMarkPerParams(item); + } + } else if (typeof node[key] === 'object') { + findAndMarkPerParams(node[key]); + } + } + } + }; + + findAndMarkPerParams(oldAST); + } + // we check for decorators we couldn't re-add for (const decorator of toRedecorate) { if (decorator.added) continue; diff --git a/src/parse/removeDecorators.ts b/src/parse/removeDecorators.ts index af212929..b25dd993 100644 --- a/src/parse/removeDecorators.ts +++ b/src/parse/removeDecorators.ts @@ -14,7 +14,11 @@ import { boolean } from 'yargs'; // regex: matches decorators when standalone words // eg: for {unknown knownknown known1 123lknown known secretvalue} finds only 1 match for decorator 'known' //const decorators = [/(? { + // Extract content between parentheses + const match = perDeclaration.match(/per\s*\(\s*([^)]+)\s*\)/); + if (!match || !match[1]) return []; + + const paramString = match[1]; + // Split by comma and parse each parameter + const params = paramString.split(',').map(param => { + const trimmed = param.trim(); + // Split by whitespace to separate type and name + const parts = trimmed.split(/\s+/); + if (parts.length >= 2) { + return { + type: parts[0], + name: parts[1] + }; + } + return null; + }).filter(p => p !== null); + + return params; +} + /** * Takes an input '.zol' file and rearranges any complete struct overwrites. * returns deDecoratedFile // a '.sol' file, where the struct overwrites @@ -280,6 +312,20 @@ function removeDecorators(options: any): { matches.push(...deDecoratedFile.matchAll(decorator)); } + // Process per(...) domain parameter declarations (in mapping declarations) + // These are different from standalone 'per' keywords in function parameters + const perMatches = [...deDecoratedFile.matchAll(perParameterPattern)]; + for (const perMatch of perMatches) { + const perParameters = parsePerParameters(perMatch[0]); + matches.push({ + index: perMatch.index, + 0: perMatch[0], + length: perMatch[0].length, + isPer: true, + perParameters: perParameters + } as any); + } + // number of chars to offset let offset = 0; @@ -301,23 +347,135 @@ function removeDecorators(options: any): { process.exit(1); } } + // Track per(...) removals to adjust charStart values later + const perRemovals: Array<{index: number, length: number, declStart?: number, matchIndex?: number, currentOffset?: number}> = []; + for (const match of matches) { // skip removal and offsetting if we're in a comment if (inComment(decoratedFile, match.index)) continue; // add this keyword length to offset, since we'll remove it (add one for the space we remove) const offsetSrcStart = match.index - offset; - // save the keyword and where the next word starts - toRedecorate.push({ decorator: match[0], charStart: offsetSrcStart }); - // replace the dedecorated file with one w/o the keyword (and remove one space) - deDecoratedFile = - deDecoratedFile.substring(0, offsetSrcStart) + - deDecoratedFile.substring(offsetSrcStart + match[0].length + 1); - offset += match[0].length + 1; + + // Handle per(...) domain parameters in mapping declarations + if ((match as any).isPer) { + // Find the start of the mapping declaration in the *de-decorated* file + // by looking backwards for 'mapping' from the current (offset-adjusted) index. + const beforePer = deDecoratedFile.substring(0, offsetSrcStart); + let mappingIndexInOriginal = beforePer.lastIndexOf('mapping'); + + // Also check for 'secret mapping' or other decorators before 'mapping'. + // We need to find the actual start of the variable declaration. + // Look backwards from 'mapping' to find the start (could be leading whitespace). + let declStart = mappingIndexInOriginal; + const beforeMapping = deDecoratedFile.substring(0, mappingIndexInOriginal); + const lastNewline = beforeMapping.lastIndexOf('\n'); + const lineStart = lastNewline === -1 ? 0 : lastNewline + 1; + + // Find the first non-whitespace character on this line in the de-decorated file + let i = lineStart; + while (i < mappingIndexInOriginal && /\s/.test(deDecoratedFile[i])) { + i++; + } + declStart = i; + + toRedecorate.push({ + decorator: 'per', + charStart: declStart, // Position of the start of the declaration in the de-decorated file + perParameters: (match as any).perParameters + }); + + // Track this removal so we can adjust charStart values later + const removalLength = match[0].length + 1; // +1 for the space after per(...) + perRemovals.push({ + index: offsetSrcStart, + length: removalLength, + declStart: declStart, // For debugging + matchIndex: match.index, // Original position in decorated file + currentOffset: offset // Offset at time of processing + }); + + // Remove the entire per(...) declaration and the space after it + // The per(...) pattern always has a space after it in valid Solidity + deDecoratedFile = + deDecoratedFile.substring(0, offsetSrcStart) + + deDecoratedFile.substring(offsetSrcStart + match[0].length + 1); + offset += match[0].length + 1; + } else if (match[0] === 'per') { + // Handle standalone 'per' keyword in function parameters + // Find the parameter that follows this 'per' keyword + const afterPer = decoratedFile.substring(match.index + 3); // 3 = length of 'per' + // Match: optional whitespace, type, whitespace, name, and optional comma/closing paren + const paramMatch = afterPer.match(/^\s+(\w+)\s+(\w+)\s*[,)]/); + + if (paramMatch) { + // Store the per parameter info for later use + const paramType = paramMatch[1]; + const paramName = paramMatch[2]; + + toRedecorate.push({ + decorator: 'per', + charStart: offsetSrcStart, + perFunctionParam: true, + paramType: paramType, + paramName: paramName, + added: true // Mark as added since we'll handle it in redecorate + }); + } else { + // Fallback: mark as added if we can't parse the parameter + toRedecorate.push({ decorator: 'per', charStart: offsetSrcStart, added: true }); + } + + // replace the dedecorated file with one w/o the keyword (and remove one space) + deDecoratedFile = + deDecoratedFile.substring(0, offsetSrcStart) + + deDecoratedFile.substring(offsetSrcStart + match[0].length + 1); + offset += match[0].length + 1; + } else { + // Handle regular decorators (secret, known, etc.) + toRedecorate.push({ decorator: match[0], charStart: offsetSrcStart }); + // replace the dedecorated file with one w/o the keyword (and remove one space) + deDecoratedFile = + deDecoratedFile.substring(0, offsetSrcStart) + + deDecoratedFile.substring(offsetSrcStart + match[0].length + 1); + offset += match[0].length + 1; + } } + // NO ADJUSTMENT NEEDED! + // Each per(...) decorator's charStart (declStart) was calculated in the de-decorated file + // at the time of processing. Since we always look backwards from the per(...) position + // to find the mapping start, and the per(...) removal happens AFTER the mapping start, + // the declStart values are already correct for the final de-decorated file. + // + // Example: + // - First per(...): declStart=356 in file with some decorators removed + // - We then remove per(...) at position 384 (AFTER 356) + // - So the mapping still starts at 356 in the final file + // + // - Second per(...): declStart=466 in file with first per(...) already removed + // - We then remove per(...) at position 494 (AFTER 466) + // - So the mapping still starts at 466 in the final file + // + // The key insight: each declStart is calculated BEFORE its corresponding per(...) is removed, + // and the removal happens AFTER the mapping start, so no adjustment is needed. + // const deDecoratedFile = deDecledLines.join('\r\n'); backtrace.setSolContract(deDecoratedFile); // store for later backtracing 'src' locators to lines of original code. + + // Debug: persist redecorate metadata for inspection + try { + const redecorateDebugPath = `${options.parseDirPath}/${options.inputFileName}_toRedecorate.json`; + const debugInfo = { + toRedecorate, + perRemovals, + perDecoratorsCount: toRedecorate.filter(d => d.decorator === 'per' && !d.perFunctionParam).length + }; + fs.writeFileSync(redecorateDebugPath, JSON.stringify(debugInfo, null, 2)); + } catch (e) { + // Non-fatal; best-effort debug output + } + const deDecoratedFilePath = `${options.parseDirPath}/${options.inputFileName}_dedecorated.sol`; fs.writeFileSync(deDecoratedFilePath, deDecoratedFile); // TODO: consider adding a 'safe' cli option to prevent overwrites. diff --git a/src/transformers/checks.ts b/src/transformers/checks.ts index c7841426..415c7088 100644 --- a/src/transformers/checks.ts +++ b/src/transformers/checks.ts @@ -15,6 +15,9 @@ import localDeclarationsVisitor from './visitors/checks/localDeclarationsVisitor import msgSenderParam from './visitors/checks/msgSenderParam.js'; import msgValueParam from './visitors/checks/msgValueParam.js'; import interactsWithSecretVisitor from './visitors/checks/interactsWithSecretVisitor.js'; +import domainConsistencyVisitor from './visitors/checks/domainConsistencyVisitor.js'; +import functionParameterVisitor from './visitors/checks/functionParameterVisitor.js'; +import mappingAccessVisitor from './visitors/checks/mappingAccessVisitor.js'; /** * Inspired by the Transformer @@ -59,6 +62,12 @@ function transformation1(oldAST: any) { logger.verbose('Pass the Correct internal calls Parameters'); path.traverse(explode(decoratorVisitor), state); logger.verbose('No conflicting known/unknown decorators'); + path.traverse(explode(domainConsistencyVisitor), state); + logger.verbose('Domain parameters are consistent'); + path.traverse(explode(functionParameterVisitor), state); + logger.verbose('Function parameters with per keyword are valid'); + path.traverse(explode(mappingAccessVisitor), state); + logger.verbose('Mapping access with domain parameters is valid'); path.traverse(explode(interactsWithSecretVisitor), state); logger.verbose('Secret interacts marked'); path.traverse(explode(incrementedVisitor), state); diff --git a/src/transformers/visitors/checks/domainConsistencyVisitor.ts b/src/transformers/visitors/checks/domainConsistencyVisitor.ts new file mode 100644 index 00000000..2e8257f4 --- /dev/null +++ b/src/transformers/visitors/checks/domainConsistencyVisitor.ts @@ -0,0 +1,136 @@ +/* eslint-disable no-param-reassign, no-unused-vars */ + +import logger from '../../../utils/logger.js'; +import backtrace from '../../../error/backtrace.js'; +import { SyntaxUsageError } from '../../../error/errors.js'; +import NodePath from '../../../traverse/NodePath.js'; +import { VariableBinding } from '../../../traverse/Binding.js'; + +/** + * Visitor validates that domain parameters are used consistently throughout the code. + * + * Checks: + * 1. All accesses to a per-mapped variable use the same domain parameters + * 2. Domain parameter types match declaration + * 3. Domain parameter names are consistent across the contract + */ + +// Track domain parameter declarations globally within a contract +const domainParameterRegistry: Map = new Map(); + +export default { + ContractDefinition: { + enter(path: NodePath) { + // Clear registry for each contract + domainParameterRegistry.clear(); + }, + + exit(path: NodePath) { + // Clear registry after contract processing + domainParameterRegistry.clear(); + }, + }, + + VariableDeclaration: { + enter(path: NodePath) { + const { node, scope } = path; + + // Only check secret mappings with per parameters + if (!node.isSecret || !node.perParameters || node.perParameters.length === 0) { + return; + } + + if (!path.isMappingDeclaration()) { + return; + } + + const mappingName = node.name; + const registryKey = `mapping:${mappingName}`; + + // Check if this mapping has been declared before with different per parameters + if (domainParameterRegistry.has(registryKey)) { + const previousDeclaration = domainParameterRegistry.get(registryKey); + + // Validate per parameter count matches + if (previousDeclaration.perParameters.length !== node.perParameters.length) { + throw new SyntaxUsageError( + `Mapping '${mappingName}' declared with ${node.perParameters.length} domain parameter(s), ` + + `but previously declared with ${previousDeclaration.perParameters.length} domain parameter(s). ` + + `Domain parameters must be consistent across all declarations.`, + node, + ); + } + + // Validate each per parameter matches + for (let i = 0; i < node.perParameters.length; i++) { + const current = node.perParameters[i]; + const previous = previousDeclaration.perParameters[i]; + + if (current.type !== previous.type) { + throw new SyntaxUsageError( + `Domain parameter '${current.name}' at position ${i + 1} has type '${current.type}', ` + + `but previously declared as '${previous.type}'. ` + + `Domain parameter types must match across all declarations.`, + node, + ); + } + + if (current.name !== previous.name) { + throw new SyntaxUsageError( + `Domain parameter at position ${i + 1} is named '${current.name}', ` + + `but previously named '${previous.name}'. ` + + `Domain parameter names must be consistent across all declarations.`, + node, + ); + } + } + } else { + // Register this mapping's domain parameters + domainParameterRegistry.set(registryKey, { + mappingName, + perParameters: node.perParameters, + node, + }); + } + + // Store per parameters in binding for later validation + const binding = scope.getReferencedBinding(node); + if (binding instanceof VariableBinding) { + binding.perParameters = node.perParameters; + } + }, + }, + + Identifier: { + exit(path: NodePath) { + const { node, scope } = path; + + // Skip special identifiers + if (path.isMsg() || path.isThis() || path.isExportedSymbol()) { + return; + } + + // Get the binding for this identifier + const binding = scope.getReferencedBinding(node); + if (!binding || !binding.stateVariable) { + return; + } + + // Check if this is a mapping with domain parameters + if (!binding.isMapping || !binding.perParameters || binding.perParameters.length === 0) { + return; + } + + // Verify the identifier is being used in a context where domain parameters are available + // This will be validated more thoroughly in the mapping access visitor + const indexAccessAncestor = path.getAncestorOfType('IndexAccess'); + if (!indexAccessAncestor) { + return; + } + + // Mark that this mapping is being accessed + binding.isReferenced = true; + }, + }, +}; + diff --git a/src/transformers/visitors/checks/functionParameterVisitor.ts b/src/transformers/visitors/checks/functionParameterVisitor.ts new file mode 100644 index 00000000..22766d6b --- /dev/null +++ b/src/transformers/visitors/checks/functionParameterVisitor.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-param-reassign, no-unused-vars */ + +import logger from '../../../utils/logger.js'; +import backtrace from '../../../error/backtrace.js'; +import { SyntaxUsageError } from '../../../error/errors.js'; +import NodePath from '../../../traverse/NodePath.js'; + +/** + * @desc: + * Visitor validates function parameters with `per` keyword. + * + * Checks: + * 1. Function parameters with `per` keyword are properly declared + * 2. Per parameters appear before regular parameters + * 3. Per parameters are not marked as secret + * 4. Per parameter types are valid + */ + +export default { + FunctionDefinition: { + enter(path: NodePath) { + const { node } = path; + + // Skip if no parameters + if (!node.parameters || !node.parameters.parameters) { + return; + } + + const params = node.parameters.parameters; + let lastPerParamIndex = -1; + const perParameters: any[] = []; + const regularParameters: any[] = []; + + // First pass: identify per parameters and validate ordering + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + // Check if parameter has per keyword (stored during parsing) + if (param.isPer) { + lastPerParamIndex = i; + perParameters.push(param); + + // Validate per parameter is not secret + if (param.isSecret) { + throw new SyntaxUsageError( + `Domain parameter '${param.name}' cannot be marked as 'secret'. ` + + `Domain parameters are part of the public API and must be public.`, + param, + ); + } + } else { + regularParameters.push(param); + + // Validate that regular parameters don't come before per parameters + if (lastPerParamIndex !== -1 && i > lastPerParamIndex) { + // This is fine - regular params can come after per params + } + } + } + + // Validate per parameters come first + for (let i = 0; i < params.length; i++) { + const param = params[i]; + if (!param.isPer && i < lastPerParamIndex) { + throw new SyntaxUsageError( + `Regular parameter '${param.name}' appears before domain parameter. ` + + `All domain parameters (with 'per' keyword) must appear before regular parameters.`, + param, + ); + } + } + + // Store per parameters in function scope for later validation + if (perParameters.length > 0) { + node.perParameters = perParameters; + } + }, + }, + + ParameterList: { + enter(path: NodePath) { + const { node, parent } = path; + + // Only process function parameters + if (parent.nodeType !== 'FunctionDefinition') { + return; + } + + if (!node.parameters) { + return; + } + + // Validate each parameter + for (const param of node.parameters) { + // Check for per keyword in parameter name or metadata + if (param.isPer) { + // Validate parameter type is valid + const typeName = param.typeName?.name || param.typeDescriptions?.typeString; + if (!typeName) { + throw new SyntaxUsageError( + `Domain parameter '${param.name}' has no type. ` + + `Domain parameters must have a valid Solidity type.`, + param, + ); + } + + // Validate parameter is not an array or mapping + if (param.typeName?.nodeType === 'ArrayTypeName') { + throw new SyntaxUsageError( + `Domain parameter '${param.name}' cannot be an array type. ` + + `Domain parameters must be scalar types (uint256, address, bytes32, etc.).`, + param, + ); + } + + if (param.typeName?.nodeType === 'Mapping') { + throw new SyntaxUsageError( + `Domain parameter '${param.name}' cannot be a mapping type. ` + + `Domain parameters must be scalar types.`, + param, + ); + } + } + } + }, + }, + + FunctionCall: { + enter(path: NodePath) { + const { node, scope } = path; + + // This will be used in Phase 3 to validate function calls with per parameters + // For now, we just mark that we've seen a function call + }, + }, +}; + diff --git a/src/transformers/visitors/checks/mappingAccessVisitor.ts b/src/transformers/visitors/checks/mappingAccessVisitor.ts new file mode 100644 index 00000000..6f34146d --- /dev/null +++ b/src/transformers/visitors/checks/mappingAccessVisitor.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-param-reassign, no-unused-vars */ + +import logger from '../../../utils/logger.js'; +import backtrace from '../../../error/backtrace.js'; +import { SyntaxUsageError } from '../../../error/errors.js'; +import NodePath from '../../../traverse/NodePath.js'; +import { VariableBinding } from '../../../traverse/Binding.js'; + +/** + * @desc: + * Visitor validates that mappings are accessed with correct domain parameters. + * + * Checks: + * 1. Mapping access includes all required domain parameters + * 2. Domain parameters are available in the current scope + * 3. Domain parameter values match expected types + */ + +export default { + IndexAccess: { + enter(path: NodePath) { + const { node, scope } = path; + + // Get the base expression (the mapping being accessed) + const baseExpression = node.baseExpression; + if (!baseExpression || baseExpression.nodeType !== 'Identifier') { + return; + } + + // Get the binding for the mapping + const binding = scope.getReferencedBinding(baseExpression); + if (!binding || !binding.stateVariable || !binding.isMapping) { + return; + } + + // Check if this mapping has domain parameters + if (!binding.perParameters || binding.perParameters.length === 0) { + return; + } + + // Get the function definition context + const functionDef = path.getFunctionDefinition(); + if (!functionDef) { + throw new SyntaxUsageError( + `Mapping '${binding.name}' with domain parameters can only be accessed within a function. ` + + `Cannot access domain-parameterized mapping at contract level.`, + node, + ); + } + + // Get function parameters + const functionParams = functionDef.node.parameters?.parameters || []; + const perParamsInFunction = functionParams.filter((p: any) => p.isPer); + + // Validate that function has required per parameters + if (perParamsInFunction.length < binding.perParameters.length) { + const missingCount = binding.perParameters.length - perParamsInFunction.length; + const missingParams = binding.perParameters + .slice(perParamsInFunction.length) + .map((p: any) => `${p.type} ${p.name}`) + .join(', '); + + throw new SyntaxUsageError( + `Mapping '${binding.name}' requires ${binding.perParameters.length} domain parameter(s), ` + + `but function '${functionDef.node.name}' only has ${perParamsInFunction.length}. ` + + `Missing: ${missingParams}. ` + + `Add these to the function signature: function ${functionDef.node.name}(per ${missingParams}, ...) ...`, + node, + ); + } + + // Validate per parameter types match + for (let i = 0; i < binding.perParameters.length; i++) { + const mappingPerParam = binding.perParameters[i]; + const functionPerParam = perParamsInFunction[i]; + + if (mappingPerParam.type !== functionPerParam.typeName?.name && + mappingPerParam.type !== functionPerParam.typeDescriptions?.typeString) { + throw new SyntaxUsageError( + `Domain parameter '${mappingPerParam.name}' at position ${i + 1} ` + + `has type '${mappingPerParam.type}' in mapping declaration, ` + + `but function parameter has type '${functionPerParam.typeName?.name || functionPerParam.typeDescriptions?.typeString}'. ` + + `Domain parameter types must match exactly.`, + node, + ); + } + + if (mappingPerParam.name !== functionPerParam.name) { + logger.warn( + `Domain parameter at position ${i + 1} is named '${mappingPerParam.name}' in mapping ` + + `but '${functionPerParam.name}' in function. ` + + `Consider using consistent names for clarity.`, + ); + } + } + + // Mark that this mapping is being accessed with domain parameters + binding.isReferenced = true; + }, + }, + + Identifier: { + exit(path: NodePath) { + const { node, scope } = path; + + // Skip special identifiers + if (path.isMsg() || path.isThis() || path.isExportedSymbol()) { + return; + } + + // Check if this identifier is a domain parameter being used + const binding = scope.getReferencedBinding(node); + if (!binding || binding.stateVariable) { + return; + } + + // Check if this is a per parameter in a function + const functionDef = path.getFunctionDefinition(); + if (!functionDef) { + return; + } + + const functionParams = functionDef.node.parameters?.parameters || []; + const isPerParam = functionParams.some((p: any) => p.name === node.name && p.isPer); + + if (isPerParam) { + // This is a valid per parameter usage + binding.isReferenced = true; + } + }, + }, +}; + diff --git a/src/transformers/visitors/common.ts b/src/transformers/visitors/common.ts index df32c6c7..abaae433 100644 --- a/src/transformers/visitors/common.ts +++ b/src/transformers/visitors/common.ts @@ -25,8 +25,11 @@ export const initialiseOrchestrationBoilerplateNodes = (fnIndicator: FunctionDef }); if (fnIndicator.oldCommitmentAccessRequired || fnIndicator.internalFunctionoldCommitmentAccessRequired) newNodes.initialisePreimageNode = buildNode('InitialisePreimage'); + // Extract function parameter names for use in boilerplate generation + const inputParameters = node.parameters?.parameters?.map((p: any) => p.name) || []; newNodes.readPreimageNode = buildNode('ReadPreimage', { contractName, + inputParameters, }); if (fnIndicator.nullifiersRequired || fnIndicator.containsAccessedOnlyState || fnIndicator.internalFunctionInteractsWithSecret) { newNodes.getInputCommitmentsNode = buildNode('GetInputCommitments', { diff --git a/src/transformers/visitors/redecorateVisitor.ts b/src/transformers/visitors/redecorateVisitor.ts index 93b8f335..12fbf551 100644 --- a/src/transformers/visitors/redecorateVisitor.ts +++ b/src/transformers/visitors/redecorateVisitor.ts @@ -40,10 +40,24 @@ export default { enter(node: any, state: any) { // for each decorator we have to re-add... for (const toRedecorate of state) { - // skip if the decorator is not secret or sharedSecret (can't be a variable dec) or if its already been added - if (toRedecorate.added || (toRedecorate.decorator !== 'secret' && toRedecorate.decorator !== 'sharedSecret')) continue; + // skip if already been added + if (toRedecorate.added) continue; + // extract the char number const srcStart = node.src.split(':')[0]; + + // Handle per(...) domain parameters + if (toRedecorate.decorator === 'per') { + if (toRedecorate.charStart === Number(srcStart)) { + toRedecorate.added = true; + node.perParameters = toRedecorate.perParameters || []; + return; + } + } + + // Handle secret/sharedSecret decorators + if (toRedecorate.decorator !== 'secret' && toRedecorate.decorator !== 'sharedSecret') continue; + // if it matches the one we removed, add it back to the AST if (toRedecorate.charStart === Number(srcStart)) { toRedecorate.added = true; @@ -91,6 +105,12 @@ export default { case 'reinitialisable': node.reinitialisable = true; return; + case 'per': + // Handle per function parameters + if (toRedecorate.perFunctionParam) { + node.isPer = true; + } + return; default: return; } @@ -98,4 +118,28 @@ export default { } }, }, + + // Catch-all for any node type that might have per(...) domain parameters + '*': { + enter(node: any, state: any) { + // Only process nodes with src property + if (!node || !node.src) return; + + // for each decorator we have to re-add... + for (const toRedecorate of state) { + // skip if already been added or not a per decorator + if (toRedecorate.added || toRedecorate.decorator !== 'per') continue; + + // extract the char number + const srcStart = node.src.split(':')[0]; + + // Handle per(...) domain parameters + if (toRedecorate.charStart === Number(srcStart)) { + toRedecorate.added = true; + node.perParameters = toRedecorate.perParameters || []; + return; + } + } + }, + }, }; diff --git a/src/transformers/visitors/toCircuitVisitor.ts b/src/transformers/visitors/toCircuitVisitor.ts index 667ce2c6..3c7412b5 100644 --- a/src/transformers/visitors/toCircuitVisitor.ts +++ b/src/transformers/visitors/toCircuitVisitor.ts @@ -506,12 +506,11 @@ const visitor = { if (node.kind === 'constructor' && state.constructorStatements && state.constructorStatements[0]) newFunctionDefinitionNode.body.statements.unshift(...state.constructorStatements); // We populate the boilerplate for the function - newFunctionDefinitionNode.parameters.parameters.push( - ...buildNode('Boilerplate', { - bpSection: 'parameters', - indicators, - }), - ); + const boilerplateParams = buildNode('Boilerplate', { + bpSection: 'parameters', + indicators, + }); + newFunctionDefinitionNode.parameters.parameters.push(...boilerplateParams); newFunctionDefinitionNode.body.preStatements.push( ...buildNode('Boilerplate', { @@ -1134,6 +1133,19 @@ const visitor = { declarationType, }); + // Set isPrivate for function parameters + // Domain parameters (per parameters) should be private + // Secret parameters should be private + // Regular parameters should be public (even if they interact with secrets) + if (declarationType === 'parameter') { + if (node.isPer) { + newNode.isPrivate = true; + } else { + // Only mark as private if explicitly declared as secret + newNode.isPrivate = node.isSecret; + } + } + if (path.isStruct(node)) { state.structNode = addStructDefinition(path); newNode.typeName.name = state.structNode.name; diff --git a/src/transformers/visitors/toOrchestrationVisitor.ts b/src/transformers/visitors/toOrchestrationVisitor.ts index 686ae5ab..070a3821 100644 --- a/src/transformers/visitors/toOrchestrationVisitor.ts +++ b/src/transformers/visitors/toOrchestrationVisitor.ts @@ -993,19 +993,83 @@ const visitor = { } // this adds other values we need in the circuit - for (const param of node._newASTPointer.parameters.parameters) { + // Get the circuit AST function node to determine the correct parameter order + // The circuit AST has files named after the Solidity function (e.g., 'deposit'), + // and each file has a 'main' function + let circuitFunctionNode: any = null; + if (state.circuitAST) { + for (const file of state.circuitAST.files) { + if (file.fileName === node.name) { + // Find the main function in this file + for (const circuitNode of file.nodes) { + if (circuitNode.nodeType === 'FunctionDefinition' && circuitNode.name === 'main') { + circuitFunctionNode = circuitNode; + break; + } + } + break; + } + } + } + + // Use circuit AST parameters if available, otherwise fall back to Solidity AST + const circuitParams = circuitFunctionNode?.parameters?.parameters || node._newASTPointer.parameters.parameters; + + // Track which parameters we've already added to avoid duplicates + const addedParams = new Set(); + + for (const param of circuitParams) { + // Expand Boilerplate nodes to get domain parameters and mapping keys + if (param.nodeType === 'Boilerplate') { + // Handle different boilerplate types + if (param.bpType === 'mapping') { + // Add domain parameters first (from perParameters) + if (param.perParameters && Array.isArray(param.perParameters)) { + for (const domainParam of param.perParameters) { + if (!addedParams.has(domainParam.name)) { + newNodes.generateProofNode.parameters.push(domainParam.name); + addedParams.add(domainParam.name); + } + } + } + + // Add mapping key parameter (if not 'local') + if (param.mappingKeyTypeName && param.mappingKeyTypeName !== 'local' && param.mappingKeyName) { + if (!addedParams.has(param.mappingKeyName)) { + newNodes.generateProofNode.parameters.push(param.mappingKeyName); + addedParams.add(param.mappingKeyName); + } + } + } + // Skip newCommitment and oldCommitmentPreimage boilerplate + // The commitment parameters are added by the orchestration boilerplate generator + + continue; + } + + // Skip if already added + if (addedParams.has(param.name)) { + continue; + } + let oldParam : any ; - for(const para of node.parameters.parameters) { - if ( para?.name === param?.name ) - oldParam = para ; - break; + for(const para of node.parameters.parameters) { + if ( para?.name === param?.name ) { + oldParam = para ; + break; + } } - if (param.isPrivate || param.isSecret || param.interactsWithSecret || scope.getReferencedIndicator(oldParam)?.interactsWithSecret) { - if (param.typeName.isStruct) { + + // Include per (domain) parameters, private parameters, secret parameters, and parameters that interact with secrets + if (oldParam?.isPer || param.isPrivate || param.isSecret || param.interactsWithSecret || scope.getReferencedIndicator(oldParam)?.interactsWithSecret) { + if (param.typeName?.isStruct) { param.typeName.properties.forEach((prop: any) => { newNodes.generateProofNode.parameters.push(`${param.name}.${prop.name}${param.typeName.isConstantArray ? '.all' : ''}`); }); - } else newNodes.generateProofNode.parameters.push(`${param.name}${param.typeName.isConstantArray ? '.all' : ''}`); + } else { + newNodes.generateProofNode.parameters.push(`${param.name}${param.typeName?.isConstantArray ? '.all' : ''}`); + addedParams.add(param.name); + } } } if (state.publicInputs) { diff --git a/src/traverse/Binding.ts b/src/traverse/Binding.ts index ed66ec53..3ad92164 100644 --- a/src/traverse/Binding.ts +++ b/src/traverse/Binding.ts @@ -176,8 +176,8 @@ export class VariableBinding extends Binding { blacklist?: any[]; - - + // Domain parameters (per keyword) + perParameters?: Array<{type: string, name: string}>; isOwned?: boolean; owner: any = null; // object of objects, indexed by node id. @@ -198,6 +198,8 @@ export class VariableBinding extends Binding { this.isSecret = node.isSecret ?? false; this.isSharedSecret = node.isSharedSecret ?? false; + // Initialize domain parameters (per keyword) + this.perParameters = node.perParameters ?? []; if (path.isMappingDeclaration() || path.isArrayDeclaration()) { this.isMapping = true; @@ -280,6 +282,19 @@ export class VariableBinding extends Binding { updateOwnership(ownerNode: any, msgIsMappingKeyorMappingValue?: string | null) { if (this.isOwned && this.owner.mappingOwnershipType === 'key') return; + + // For mapping states, ignore caller-restriction-based owners that clearly + // refer to a different mapping. This avoids spurious "two distinct owners" + // errors when a function both (a) restricts msg.sender via one mapping and + // (b) nullifies a different mapping. + if (this.isMapping && !msgIsMappingKeyorMappingValue && ownerNode.baseExpression) { + const referencedDeclaration = ownerNode.baseExpression.referencedDeclaration; + if (referencedDeclaration && referencedDeclaration !== this.id) { + // This restriction is not about this mapping; skip it for this binding. + return; + } + } + if ( ownerNode.expression?.name === 'msg' && msgIsMappingKeyorMappingValue === 'value' @@ -646,4 +661,67 @@ export class VariableBinding extends Binding { // TODO more useful indicators here } } + + /** + * Validates that domain parameters are consistent with a given set of parameters + * @param {Array} otherPerParameters - The domain parameters to compare against + * @param {string} context - Context for error messages (e.g., "function parameter") + * @returns {boolean} - True if parameters match, throws error otherwise + */ + validateDomainParameterConsistency(otherPerParameters: Array<{type: string, name: string}>, context: string = 'function'): boolean { + if (!this.perParameters || this.perParameters.length === 0) { + return true; + } + + if (!otherPerParameters || otherPerParameters.length === 0) { + throw new SyntaxUsageError( + `Mapping '${this.name}' requires ${this.perParameters.length} domain parameter(s), ` + + `but the ${context} has none. ` + + `Add domain parameters: ${this.perParameters.map(p => `per ${p.type} ${p.name}`).join(', ')}`, + this.node, + ); + } + + if (this.perParameters.length !== otherPerParameters.length) { + throw new SyntaxUsageError( + `Mapping '${this.name}' requires ${this.perParameters.length} domain parameter(s), ` + + `but the ${context} has ${otherPerParameters.length}. ` + + `Expected: ${this.perParameters.map(p => `${p.type} ${p.name}`).join(', ')}`, + this.node, + ); + } + + for (let i = 0; i < this.perParameters.length; i++) { + const mappingParam = this.perParameters[i]; + const otherParam = otherPerParameters[i]; + + if (mappingParam.type !== otherParam.type) { + throw new SyntaxUsageError( + `Domain parameter '${mappingParam.name}' at position ${i + 1} ` + + `has type '${mappingParam.type}' in mapping '${this.name}', ` + + `but type '${otherParam.type}' in the ${context}. ` + + `Domain parameter types must match exactly.`, + this.node, + ); + } + } + + return true; + } + + /** + * Gets the domain parameters required for this binding + * @returns {Array} - Array of domain parameter objects + */ + getDomainParameters(): Array<{type: string, name: string}> { + return this.perParameters || []; + } + + /** + * Checks if this binding has domain parameters + * @returns {boolean} - True if binding has domain parameters + */ + hasDomainParameters(): boolean { + return this.perParameters && this.perParameters.length > 0; + } } diff --git a/src/traverse/MappingKey.ts b/src/traverse/MappingKey.ts index 7559dcaf..91e69d6a 100644 --- a/src/traverse/MappingKey.ts +++ b/src/traverse/MappingKey.ts @@ -76,6 +76,9 @@ export default class MappingKey { owner: any = null; // object of objects, indexed by node id. encryptionRequired?: boolean; + // Domain parameters (per keyword) + perParameters?: Array<{type: string, name: string}>; + returnKeyName(keyNode: any) { if (this.keyPath.isMsgSender(keyNode)) return 'msgSender'; if (this.keyPath.isMsgValue(keyNode)) return 'msgValue'; @@ -113,6 +116,9 @@ export default class MappingKey { this.isSecret = container.isSecret; this.isSharedSecret = container.isSharedSecret; + // Initialize domain parameters from container + this.perParameters = container.perParameters ?? []; + this.isMapping = container.isMapping; this.isStruct = container.isStruct; // keyPath.isStruct(); diff --git a/src/traverse/NodePath.ts b/src/traverse/NodePath.ts index c549bd79..5a6bf480 100644 --- a/src/traverse/NodePath.ts +++ b/src/traverse/NodePath.ts @@ -635,6 +635,7 @@ export default class NodePath { } isExternalContractInstanceDeclaration(node: any = this.node): boolean { + if (!node) return false; if ( !['VariableDeclaration', 'VariableDeclarationStatement'].includes( node.nodeType, diff --git a/test/contracts/user-friendly-tests/NFT_Escrow_DomainParams.zol b/test/contracts/user-friendly-tests/NFT_Escrow_DomainParams.zol new file mode 100644 index 00000000..727e7119 --- /dev/null +++ b/test/contracts/user-friendly-tests/NFT_Escrow_DomainParams.zol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: CC0 +// NFT Escrow with Domain Parameters (Phase 3 Test) +// This contract supports multiple NFT contracts with proper domain isolation + +pragma solidity ^0.8.0; + +import "./Escrow-imports/IERC721.sol"; + +contract NFT_Escrow_DomainParams { + + // Domain parameter: nftContract + // This allows tracking tokens from multiple NFT contracts + // Each nftContract address creates a separate cryptographic namespace + // stateVarId = mimc2([mimc2([mappingId, nftContract]), tokenId]) + secret mapping(uint256 => address) per(address nftContract) public tokenOwners; + + // Domain-scoped approvals: one namespace per nftContract + secret mapping(address => address) per(address nftContract) public approvals; + + // No hardcoded ERC721 instance - we use the nftContract parameter directly + // This allows the escrow to work with any ERC721 contract + + // Function with domain parameter + // The nftContract parameter is used for BOTH: + // 1. The actual NFT transfer (via IERC721 interface) + // 2. Cryptographic domain separation (state variable ID calculation) + function deposit(per address nftContract, uint256 tokenId) public { + require(nftContract != address(0), "NFT_Escrow: invalid nftContract"); + + // Use the nftContract parameter to instantiate the ERC721 interface + IERC721 nft = IERC721(nftContract); + bool success = nft.transferFrom(msg.sender, address(this), tokenId); + require(success, "NFT_Escrow: ERC721 transfer failed"); + + // stateVarId calculation includes nftContract domain parameter + reinitialisable tokenOwners[tokenId] = msg.sender; + } + + function transfer(per address nftContract, secret address recipient, secret uint256 tokenId) public { + require(nftContract != address(0), "NFT_Escrow: invalid nftContract"); + require(tokenOwners[tokenId] == msg.sender); + require(recipient != address(0), "NFT_Escrow: transfer to the zero address"); + tokenOwners[tokenId] = recipient; + } + + function approve(per address nftContract, secret address approvedAddress) public { + require(nftContract != address(0), "NFT_Escrow: invalid nftContract"); + require(approvedAddress != address(0), "Escrow: approve to the zero address"); + approvals[msg.sender] = approvedAddress; + } + + function transferFrom(per address nftContract, secret address sender, secret address recipient, secret uint256 tokenId) public { + require(nftContract != address(0), "NFT_Escrow: invalid nftContract"); + require(recipient != address(0), "NFT_Escrow: transfer to the zero address"); + require(sender != address(0), "NFT_Escrow: transfer from the zero address"); + + // Approval: sender (owner) has approved msg.sender in this nftContract domain + require(approvals[sender] == msg.sender, "NFT_Escrow: not approved"); + + // Ownership: sender actually owns tokenId in this nftContract's domain + require(tokenOwners[tokenId] == sender, "NFT_Escrow: sender does not own token"); + + tokenOwners[tokenId] = recipient; + } + + function withdraw(per address nftContract, uint256 tokenId) public { + require(nftContract != address(0), "NFT_Escrow: invalid nftContract"); + require(tokenOwners[tokenId] == msg.sender); + + // Use the nftContract parameter to instantiate the ERC721 interface + IERC721 nft = IERC721(nftContract); + bool success = nft.transferFrom(address(this), msg.sender, tokenId); + require(success, "ERC721 transfer failed"); + + tokenOwners[tokenId] = address(0); + } +} +