Skip to main content

WorkProof: signed photo + voice + GPS, anchored to Polygon Amoy

Share:XLinkedInHN
Cover for WorkProof: signed photo + voice + GPS, anchored to Polygon Amoy

The scenario I built this for

A plumber finishes a job. The homeowner is not there, because the homeowner is at work, because that is why the plumber is here on a Tuesday afternoon in the first place. The plumber wants to prove that the work happened, that the water ran for two minutes with no leaks, that the trap under the kitchen sink is now the trap the invoice describes. Today that proof is a photo texted to a number, and if it turns into a dispute six months later the photo has been through three phones, two WhatsApp backups, and a screenshot round trip, and nobody can say when it was taken or where.

WorkProof is a 90-second flow that takes the phone the crew already carries and produces one artifact the homeowner can keep. Photo, voice memo, GPS, transcript, a device-signed hash, exported as a PDF with a QR code. The hash also lands on Polygon Amoy so there is a public timestamp that nobody in the chain of custody can walk back.

The repo is kaushiksaravanan/workproof. The mobile side is Expo / React Native. The contract is nine lines of Solidity. The rest is glue that has to survive a spotty rural LTE connection.

Four artifacts, one signature

The bundle has four parts, and the signature has to bind them together so you cannot swap one out later and keep the seal.

  1. The photo. expo-camera capture, JPEG on disk.
  2. The voice memo. expo-av hold-to-record, m4a on disk.
  3. The GPS point. expo-location at fine accuracy, plus the wall-clock timestamp.
  4. The transcript. A text field the worker types or dictates by hand, because on-device speech-to-text is not wired up yet and the copy in the app says so out loud. The transcript is editable inline before signing.

The naive move here is to concatenate the file paths and hash the string. That does not work, because file paths on Android live inside sandboxed content URIs that get rewritten every install and every backup restore. So the record hash is built from content, not identifiers. app/src/services/hashing.ts walks like this:

  1. SHA-256 of the raw photo bytes, lowercase hex. That is photoHash.
  2. SHA-256 of the raw audio bytes if there is one. That is audioHash.
  3. Take the WorkRecord object, replace photoUri with photoHash and audioUri with audioHash.
  4. JSON.stringify with keys sorted alphabetically. That is the canonical string.
  5. SHA-256 of the UTF-8 bytes of that canonical string. That is the record hash.

A verifier who has the PDF and the two media files can rerun the recipe with sha256sum and a five-line Python script. If the digest matches, the bundle is intact. If it does not, something moved.

That digest is then signed with the device's ethers.js wallet, and the signature plus the recovered address get printed on the PDF next to the photo. The PDF also embeds the same digest in the QR code so a phone camera can pull it out without OCR.

Why Polygon Amoy was the right target

Amoy is the Polygon PoS testnet. It is what Sepolia is to Ethereum. I picked it on purpose, and I want to be direct about that because it looks like a cost dodge and it is not.

WorkProof is a demo of a workflow, not a live production ledger. The value the anchor adds is a public, non-repudiable timestamp that a small-claims judge can check by clicking one link. For the demo path to actually run end-to-end on a stranger's machine, the anchor step has to happen without the stranger topping up a real wallet with real MATIC and handing me a mainnet key over a chat window. Amoy solves that in two ways. The faucet at faucet.polygon.technology drips enough test MATIC to submit thousands of anchor transactions, and the explorer at amoy.polygonscan.com renders those transactions with the same UI as mainnet so the demo looks identical to what a production deployment would look like. If you strip Amoy out and point the app at Polygon mainnet, the code path does not change. It is the same contract, the same ABI, the same call. You swap the RPC URL, fund a hot wallet, and go.

I wanted the demo to be honest about what it was doing on chain, which meant showing a real transaction on a real block explorer. I did not want the demo to be honest at the cost of asking a reviewer to spend real money. Amoy was the exact intersection.

The anchor itself is deliberately small. WorkProofAnchor.sol is:

contract WorkProofAnchor {
    event Anchored(bytes32 indexed hash, address indexed worker, uint256 timestamp);
 
    function anchor(bytes32 hash) external {
        emit Anchored(hash, msg.sender, block.timestamp);
    }
}

Nothing more. There is no registry, no on-chain lookup, no per-record storage slot, no admin role. The event log is the storage. Indexing by hash and worker means a verifier can query eth_getLogs for a specific digest and get back the block timestamp and the signing address. That is the anchor. Anything richer would push data into SSTORE and multiply the gas cost for no gain the demo needs.

On the phone side, app/src/services/anchor.ts calls contract.anchor(getBytes("0x" + hashHex)) with the demo signing key, waits for the receipt, and hands the UI a Polygonscan URL. Everything about that call is idiomatic ethers v6, except for two mutexes I had to add: one over the AsyncStorage queue so two concurrent enqueues cannot clobber each other across an await, and one over the submit path so two concurrent anchor calls do not both read the same pending nonce and submit twice as nonce N.

Expo constraints that shaped the flow

Building a capture app in Expo Go, with no dev client, forces you to work with permissions and background behavior the way Android hands them to you, not the way you want them.

Camera and microphone permissions are runtime. The first tap on "New proof" is the first time the OS prompts. If the user declines microphone, the flow has to fall back to photo-only, which changes the signature composition, which changes the hash recipe. Rather than branch the recipe, I made the audio hash conditional on audioBytes being present. Same canonical function, one optional field, same downstream verifier code path.

Fine location is a separate prompt. Denying it is not fatal, but it strips one of the four artifacts, and the PDF then shows a no location marker in the corner. I wrote that string into the layout on purpose, so an auditor looking at a proof six months later can tell the difference between a proof that lost GPS and one that never had it.

Background upload does not exist in Expo Go the way it exists on a bare RN app with expo-task-manager and native modules. The anchor path assumes foreground submission. If the phone drops the LTE connection mid-transaction, the code catches the RPC failure, pushes the hash into an AsyncStorage queue, and returns a synthetic queued:<hash> tx id so the UI can render "queued" state instead of a red error. flushQueue runs when the app comes back to foreground on a good connection. That is the retry story, and it is honest about being a foreground story. A crew that logs ten proofs on a rural drive and comes home to WiFi will get ten confirmed anchors within a minute of walking through the door.

The other Expo constraint worth calling out: expo-print renders the PDF from an HTML string, which means the photo goes into an <img src>. That is a live-rendered surface if the HTML ever escapes the print pipeline, so app/src/services/proof.ts whitelists the URL scheme against file:, content:, http(s):, and data:image/. Anything else, including a stray javascript: from a paste, gets replaced with the empty string. It is a small hardening, but the code that produces the artifact should not also be the code that lets a hostile string through.

What a v2 would add

If this had real users tomorrow, three things would move first.

Background sync as a first-class dev-client feature, not the AsyncStorage foreground queue. A crew that spends the day out of range should be able to close the app and trust that the anchor lands when the phone reconnects, without them opening WorkProof again.

A hosted verifier at a stable URL. Right now the QR on the PDF points to a placeholder. The verifier is straightforward, take the digest, hit eth_getLogs on Amoy for that indexed topic, render the timestamp and the signer, done. But it has to live somewhere the homeowner can trust for as long as the proof might matter, which is years.

Key custody for the crew. The current demo uses a shared hackathon signing key that lives in an environment variable, which is fine for a demo and wrong for a real product. Each device should have its own key, generated on first launch, backed up to the OS keystore, and rotatable if a phone is lost. That is a whole project of its own, and the reason WorkProof stops at demo-grade until it exists.

Everything else is fit and finish. The core loop, capture, hash, sign, anchor, share, works today, and it works on a phone the crew already owns.

Cite as: Saravanan, K. (2026). WorkProof: signed photo + voice + GPS, anchored to Polygon Amoy. Kaushik Saravanan. https://www.kaushik.cv/blog/workproof-photo-voice-gps-polygon-amoy