DEV Community

Cover image for INTEGRATING PHYSICAL VERIFICATION INTO YOUR RWA PLATFORM: A STEP-BY-STEP GUIDE WITH PTVS v1.0
Aurelio Tamarit Blay
Aurelio Tamarit Blay

Posted on

INTEGRATING PHYSICAL VERIFICATION INTO YOUR RWA PLATFORM: A STEP-BY-STEP GUIDE WITH PTVS v1.0

INTEGRATING PHYSICAL VERIFICATION INTO YOUR RWA PLATFORM: A STEP-BY-STEP GUIDE WITH PTVS v1.0

How to add on-chain physical verification to tokenized real-world assets using PTVSClaimInjector.sol

If you're building an RWA tokenization platform, you've probably hit this wall:

Your smart contract knows who owns the token, but it doesn't know if the asset behind the token still exists.

MiCA Art. 36 requires continuous proof of reserve assets. Price oracles give you market value, but they can't verify physical integrity. You need physical verification — and it needs to be on-chain, deterministic, and legally admissible.

In this tutorial, I'll show you how to integrate the Prop Trust Verified Standard (PTVS v1.0) into your existing RWA platform using the open-source PTVSClaimInjector.sol smart contract.

What you'll build:

  • Deploy PTVSClaimInjector on a testnet
  • Generate a forensic inspection report (canonical JSON)
  • Compute SHA-256 hash
  • Inject a Verifiable Claim on-chain
  • Query the claim from your dApp
  • Integrate with ERC-3643/T-REX for compliance

Time required: approximately 1 hour
Prerequisites: Basic Solidity, familiarity with Hardhat or Foundry, MetaMask wallet

Let's build.

STEP 1: UNDERSTAND THE ARCHITECTURE

Before writing code, let's understand the data flow:

Physical Asset
|
v
PTCE Expert (on-site inspection)
|
v
PDF/A Report + QES Signature
|
v
Canonical JSON (deterministic serialization)
|
v
SHA-256 Hash (forensicHash)
|
v
PTVSClaimInjector.sol (on-chain)
|
v
Verifiable Claim stored
|
v
Circuit breaker logic triggered
|
v
ERC-3643/T-REX compliance enforced

Key concept: The forensicHash is the cryptographic link between the physical evidence (PDF/A report) and the on-chain claim. Any modification to the report changes the hash, making tampering detectable.

STEP 2: DEPLOY PTVSCLAIMINJECTOR

2.1 Get the Contract

PTVSClaimInjector is open-source (MIT License). Clone the repository:

git clone https://github.com/aurema-group/ptvs-sdk-python.git
cd ptvs-sdk-python
Enter fullscreen mode Exit fullscreen mode

Or install via npm (for JavaScript/TypeScript projects):

npm install @aurema-group/ptvs-sdk
Enter fullscreen mode Exit fullscreen mode

2.2 Contract Source Code

Here's the complete PTVSClaimInjector.sol contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";

contract PTVSClaimInjector is Ownable {

    enum ClaimStatus { VERIFIED, CONDITIONAL, EXPIRED, REVOKED }

    struct VerifiableClaim {
        bytes32 assetId;
        uint8 ptvsScore;
        bytes32 forensicHash;
        uint256 inspectionTimestamp;
        address ptceAddress;
        ClaimStatus status;
    }

    mapping(bytes32 => VerifiableClaim) public claims;
    mapping(address => bool) public authorizedPTCEs;

    event ClaimInjected(
        bytes32 indexed assetId,
        uint8 ptvsScore,
        bytes32 forensicHash,
        address indexed ptceAddress,
        uint256 timestamp
    );

    event ClaimRenewed(
        bytes32 indexed assetId,
        uint8 newScore,
        bytes32 newHash,
        uint256 timestamp
    );

    event ClaimRevoked(
        bytes32 indexed assetId,
        string reason,
        uint256 timestamp
    );

    event CircuitBreakerTriggered(
        bytes32 indexed assetId,
        uint8 score,
        string action,
        uint256 timestamp
    );

    modifier onlyAuthorizedPTCE() {
        require(authorizedPTCEs[msg.sender], "Not authorized PTCE");
        _;
    }

    constructor() Ownable(msg.sender) {}

    function authorizePTCE(address ptce) external onlyOwner {
        authorizedPTCEs[ptce] = true;
    }

    function revokePTCE(address ptce) external onlyOwner {
        authorizedPTCEs[ptce] = false;
    }

    function injectClaim(
        bytes32 assetId,
        uint8 ptvsScore,
        bytes32 forensicHash,
        uint256 inspectionTimestamp,
        bytes memory ptceSignature
    ) external onlyAuthorizedPTCE {

        ClaimStatus status;
        if (ptvsScore >= 70) {
            status = ClaimStatus.VERIFIED;
        } else if (ptvsScore >= 50) {
            status = ClaimStatus.CONDITIONAL;
        } else {
            status = ClaimStatus.REVOKED;
        }

        claims[assetId] = VerifiableClaim({
            assetId: assetId,
            ptvsScore: ptvsScore,
            forensicHash: forensicHash,
            inspectionTimestamp: inspectionTimestamp,
            ptceAddress: msg.sender,
            status: status
        });

        emit ClaimInjected(
            assetId,
            ptvsScore,
            forensicHash,
            msg.sender,
            block.timestamp
        );

        // Trigger circuit breaker if needed
        if (status == ClaimStatus.REVOKED) {
            emit CircuitBreakerTriggered(
                assetId,
                ptvsScore,
                "Trading paused",
                block.timestamp
            );
        }
    }

    function renewClaim(
        bytes32 assetId,
        uint8 newScore,
        bytes32 newHash,
        uint256 newTimestamp,
        bytes memory ptceSignature
    ) external onlyAuthorizedPTCE {
        require(claims[assetId].assetId != bytes32(0), "Claim does not exist");

        ClaimStatus newStatus;
        if (newScore >= 70) {
            newStatus = ClaimStatus.VERIFIED;
        } else if (newScore >= 50) {
            newStatus = ClaimStatus.CONDITIONAL;
        } else {
            newStatus = ClaimStatus.REVOKED;
        }

        claims[assetId] = VerifiableClaim({
            assetId: assetId,
            ptvsScore: newScore,
            forensicHash: newHash,
            inspectionTimestamp: newTimestamp,
            ptceAddress: msg.sender,
            status: newStatus
        });

        emit ClaimRenewed(assetId, newScore, newHash, block.timestamp);
    }

    function revokeClaim(
        bytes32 assetId,
        string calldata reason
    ) external onlyOwner {
        require(claims[assetId].assetId != bytes32(0), "Claim does not exist");

        claims[assetId].status = ClaimStatus.REVOKED;

        emit ClaimRevoked(assetId, reason, block.timestamp);
        emit CircuitBreakerTriggered(
            assetId,
            claims[assetId].ptvsScore,
            reason,
            block.timestamp
        );
    }

    function getClaim(bytes32 assetId) 
        external 
        view 
        returns (VerifiableClaim memory) 
    {
        return claims[assetId];
    }

    function isClaimValid(bytes32 assetId) external view returns (bool) {
        VerifiableClaim memory claim = claims[assetId];
        return claim.status == ClaimStatus.VERIFIED;
    }
}
Enter fullscreen mode Exit fullscreen mode

2.3 Deploy to Testnet

Using Hardhat:

npx hardhat run scripts/deploy.js --network sepolia
Enter fullscreen mode Exit fullscreen mode

Or using Foundry:

forge create src/PTVSClaimInjector.sol:PTVSClaimInjector --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
Enter fullscreen mode Exit fullscreen mode

Save the deployed contract address — you'll need it for the next steps.

STEP 3: GENERATE CANONICAL JSON AND COMPUTE HASH

3.1 Create Inspection Data

First, create the inspection findings:

Python example:

import json
from Crypto.Hash import SHA256

inspection_data = {
    "assetId": "0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef12345678",
    "assetName": "Edificio residencial · Calle Mayor 42, Valencia",
    "assetType": "real_estate",
    "inspectionDate": "2026-08-15T10:30:00Z",
    "jurisdiction": "ES-VC",
    "ptceId": "PTCE-0161",
    "ptvsScore": 85,
    "structural": {
        "score": 85,
        "cracks": 0,
        "corrosion": 1,
        "degradation": 0
    },
    "legal": {
        "score": 92,
        "encumbrances": 0,
        "titleClear": True
    },
    "environmental": {
        "score": 78,
        "contamination": 0,
        "hazards": 0
    },
    "documentation": {
        "score": 95
    },
    "insurance": {
        "score": 88,
        "active": True
    },
    "notes": "Minor corrosion detected on balcony railings. Recommend treatment within 6 months."
}
Enter fullscreen mode Exit fullscreen mode

3.2 Canonicalize JSON

Canonical JSON ensures deterministic serialization (sorted keys, no whitespace):

# Canonicalize: sort keys alphabetically, remove whitespace
canonical_json = json.dumps(
    inspection_data, 
    sort_keys=True, 
    separators=(',', ':'),
    ensure_ascii=False
)

print("Canonical JSON:")
print(canonical_json)
Enter fullscreen mode Exit fullscreen mode

Output example:

{"assetId":"0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef12345678","assetName":"Edificio residencial · Calle Mayor 42, Valencia","assetType":"real_estate",...}
Enter fullscreen mode Exit fullscreen mode

3.3 Compute SHA-256 Hash

# Compute SHA-256 hash
hash_obj = SHA256.new(canonical_json.encode('utf-8'))
forensic_hash = '0x' + hash_obj.hexdigest()

print(f"Forensic Hash: {forensic_hash}")
# Output: 0xa3f2b8c9d4e5f6789012345678901234567890abcdef1234567890abcdef12345678
Enter fullscreen mode Exit fullscreen mode

This hash is what you'll inject on-chain. It's the cryptographic link between your physical evidence and the on-chain claim.

STEP 4: INJECT CLAIM ON-CHAIN

4.1 Python Integration (Web3.py)

from web3 import Web3

# Connect to testnet
w3 = Web3(Web3.HTTPProvider('https://sepolia.infura.io/v3/YOUR_PROJECT_ID'))

# Contract ABI (simplified)
abi = [
    {
        "inputs": [
            {"name": "assetId", "type": "bytes32"},
            {"name": "ptvsScore", "type": "uint8"},
            {"name": "forensicHash", "type": "bytes32"},
            {"name": "inspectionTimestamp", "type": "uint256"},
            {"name": "ptceSignature", "type": "bytes"}
        ],
        "name": "injectClaim",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [{"name": "assetId", "type": "bytes32"}],
        "name": "getClaim",
        "outputs": [
            {
                "components": [
                    {"name": "assetId", "type": "bytes32"},
                    {"name": "ptvsScore", "type": "uint8"},
                    {"name": "forensicHash", "type": "bytes32"},
                    {"name": "inspectionTimestamp", "type": "uint256"},
                    {"name": "ptceAddress", "type": "address"},
                    {"name": "status", "type": "uint8"}
                ],
                "name": "",
                "type": "tuple"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    }
]

# Load contract
contract_address = '0xYOUR_DEPLOYED_CONTRACT_ADDRESS'
contract = w3.eth.contract(address=contract_address, abi=abi)

# PTCE account (must be authorized)
ptce_account = w3.eth.account.from_key('YOUR_PTCE_PRIVATE_KEY')

# Prepare transaction
asset_id = bytes.fromhex(inspection_data['assetId'][2:])  # Remove '0x'
ptvs_score = inspection_data['ptvsScore']
forensic_hash = bytes.fromhex(forensic_hash[2:])  # Remove '0x'
inspection_timestamp = int(datetime.now().timestamp())
ptce_signature = b''  # In production, this would be the QES signature

# Build transaction
nonce = w3.eth.get_transaction_count(ptce_account.address)
tx = contract.functions.injectClaim(
    asset_id,
    ptvs_score,
    forensic_hash,
    inspection_timestamp,
    ptce_signature
).build_transaction({
    'from': ptce_account.address,
    'nonce': nonce,
    'gas': 200000,
    'gasPrice': w3.eth.gas_price
})

# Sign and send
signed_tx = ptce_account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)

print(f"Transaction sent: {tx_hash.hex()}")

# Wait for confirmation
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"Transaction confirmed in block {receipt.blockNumber}")
Enter fullscreen mode Exit fullscreen mode

4.2 JavaScript Integration (Web3.js)

const Web3 = require('web3');
const crypto = require('crypto');

// Connect to testnet
const web3 = new Web3('https://sepolia.infura.io/v3/YOUR_PROJECT_ID');

// Contract instance
const contractAddress = '0xYOUR_DEPLOYED_CONTRACT_ADDRESS';
const contract = new web3.eth.Contract(ABI, contractAddress);

// PTCE account
const ptceAccount = web3.eth.accounts.privateKeyToAccount('YOUR_PTCE_PRIVATE_KEY');
web3.eth.accounts.wallet.add(ptceAccount);

// Inspection data
const inspectionData = {
  assetId: '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef12345678',
  ptvsScore: 85,
  // ... rest of inspection data
};

// Compute forensic hash (same as Python example)
const canonicalJson = JSON.stringify(inspectionData, Object.keys(inspectionData).sort());
const forensicHash = '0x' + crypto.createHash('sha256').update(canonicalJson).digest('hex');

// Inject claim
async function injectClaim() {
  const tx = await contract.methods.injectClaim(
    inspectionData.assetId,
    inspectionData.ptvsScore,
    forensicHash,
    Math.floor(Date.now() / 1000),
    '0x'  // ptceSignature
  ).send({
    from: ptceAccount.address,
    gas: 200000
  });

  console.log('Transaction hash:', tx.transactionHash);
  return tx;
}

injectClaim().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

STEP 5: QUERY AND VERIFY CLAIMS

5.1 Read Claim from Contract

# Query claim
claim = contract.functions.getClaim(asset_id).call()

print(f"Asset ID: 0x{claim[0].hex()}")
print(f"PTVS Score: {claim[1]}")
print(f"Forensic Hash: 0x{claim[2].hex()}")
print(f"Inspection Timestamp: {claim[3]}")
print(f"PTCE Address: {claim[4]}")
print(f"Status: {['VERIFIED', 'CONDITIONAL', 'EXPIRED', 'REVOKED'][claim[5]]}")
Enter fullscreen mode Exit fullscreen mode

5.2 Verify Claim Independently

Any third party can verify the claim by recomputing the hash:

def verify_claim(claim, original_json):
    """Verify that on-chain claim matches original inspection report."""

    # Recompute hash from original JSON
    recomputed_hash = '0x' + SHA256.new(original_json.encode('utf-8')).hexdigest()

    # Compare with on-chain hash
    on_chain_hash = '0x' + claim[2].hex()

    if recomputed_hash == on_chain_hash:
        print("Verification successful: hashes match")
        return True
    else:
        print("Verification failed: hashes do not match")
        print(f"  Expected: {on_chain_hash}")
        print(f"  Got:      {recomputed_hash}")
        return False

# Verify the claim
verify_claim(claim, canonical_json)
Enter fullscreen mode Exit fullscreen mode

STEP 6: INTEGRATE WITH ERC-3643/T-REX

If you're using ERC-3643/T-REX for identity and compliance, here's how to integrate PTVS:

6.1 Architecture

ERC-3643/T-REX
├─ Identity Registry (ONCHAINID)
├─ Compliance Module
└─ Token Contract
                ↕
PTVSClaimInjector (PTVS v1.0)
├─ Physical verification claims
├─ PTVS Score enforcement
└─ Circuit breaker actions
Enter fullscreen mode Exit fullscreen mode

6.2 Compliance Integration Example

// In your token contract
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./PTVSClaimInjector.sol";

contract RWA_Token is ERC20 {
    PTVSClaimInjector public ptvsInjector;
    bytes32 public assetId;

    constructor(
        address _ptvsInjector,
        bytes32 _assetId
    ) ERC20("Real World Asset Token", "RWAT") {
        ptvsInjector = PTVSClaimInjector(_ptvsInjector);
        assetId = _assetId;
    }

    function transfer(address to, uint256 amount) public override returns (bool) {
        // Check PTVS claim before allowing transfer
        require(
            ptvsInjector.isClaimValid(assetId),
            "Transfer blocked: asset verification expired or revoked"
        );

        return super.transfer(to, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that tokens cannot be transferred if the physical asset verification has expired or been revoked.

STEP 7: BUILD A FRONTEND VERIFICATION WIDGET

7.1 HTML + JavaScript Widget

<!DOCTYPE html>
<html>
<head>
    <title>PTVS Verification Widget</title>
    <script src="https://cdn.jsdelivr.net/npm/web3@latest/dist/web3.min.js"></script>
</head>
<body>
    <h1>Asset Verification</h1>
    <input type="text" id="assetId" placeholder="Enter Asset ID (0x...)">
    <button onclick="verifyAsset()">Verify</button>

    <div id="result"></div>

    <script>
        const web3 = new Web3('https://sepolia.infura.io/v3/YOUR_PROJECT_ID');
        const contractAddress = '0xYOUR_DEPLOYED_CONTRACT_ADDRESS';
        const contractABI = [/* ABI here */];
        const contract = new web3.eth.Contract(contractABI, contractAddress);

        async function verifyAsset() {
            const assetId = document.getElementById('assetId').value;
            const claim = await contract.methods.getClaim(assetId).call();

            const statusMap = ['VERIFIED', 'CONDITIONAL', 'EXPIRED', 'REVOKED'];
            const status = statusMap[claim.status];

            let html = `
                <h2>Verification Result</h2>
                <p><strong>Asset ID:</strong> ${claim.assetId}</p>
                <p><strong>PTVS Score:</strong> ${claim.ptvsScore}/100</p>
                <p><strong>Status:</strong> ${status}</p>
                <p><strong>Inspection Date:</strong> ${new Date(claim.inspectionTimestamp * 1000).toLocaleString()}</p>
                <p><strong>PTCE Address:</strong> ${claim.ptceAddress}</p>
            `;

            if (status === 'VERIFIED') {
                html += '<p style="color: green;">Asset is verified and eligible for trading</p>';
            } else if (status === 'CONDITIONAL') {
                html += '<p style="color: orange;">Asset is conditional - increased risk</p>';
            } else {
                html += '<p style="color: red;">Asset verification revoked - trading paused</p>';
            }

            document.getElementById('result').innerHTML = html;
        }
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

STEP 8: TEST THE FULL FLOW

8.1 Try the Sandbox First

Before deploying to production, test the complete flow in our public sandbox:

https://proptrustverified.com/sandbox/

The sandbox lets you:

  1. Define a mock asset
  2. Run a forensic inspection
  3. Compute SHA-256 hash
  4. Simulate on-chain claim injection
  5. Verify the claim

No backend required. All computations happen in your browser.

8.2 Production Checklist

Before going to mainnet:

  • Security audit of your integration code
  • Deploy PTVSClaimInjector to mainnet (or L2 like Arbitrum/Polygon for lower gas)
  • Authorize your PTCE addresses
  • Test circuit breaker logic with mock degraded assets
  • Integrate with your existing compliance workflow
  • Add monitoring for claim expiration (90-day re-audit cycle)
  • Document the verification process for your users

REAL-WORLD EXAMPLE: PROYECTO NAVARRÉS

To see this in action with a real asset, check out Proyecto Navarrés:

This demonstrates that PTVS v1.0 is not theoretical — it's been executed against real assets.

RESOURCES

QUESTIONS?

If you run into issues integrating PTVS, drop a comment below or reach out:

Happy building!

Aurelio Tamarit Blay is the Lead Researcher of the Forensics Oracle Initiative and creator of the Prop Trust Verified Standard (PTVS v1.0). He is a sworn judicial expert (Exp. No. 0161, Spain) with 33 years of forensic practice. ORCID: 0009-0007-5824-3602.

Tags: blockchain smartcontracts rwa solidity

Top comments (0)