The visible demonstration takes less than a minute.
A drone lands on an Arkreen eCandle. The devices connect over Bluetooth Low Energy. The eCandle supplies power and requests payment based on usage. The drone signs a USDC payment authorisation every five seconds. The eCandle batches several authorisations and forwards them for settlement. Later, the seller signs a different intent to move cleared value into USDC on Arc.
Underneath that sequence are two signatures with different signers, domains and effects.
The first signature is created by the drone and authorises a usage-based payment. The second is created by the eCandle seller and authorises the final move to Arc. Confusing the two would make the system look simpler than it is — and would incorrectly suggest that every five-second slice is an Arc transaction. In the September 17 show, 927 payment authorisations were settled through 12 Arc transactions. That ratio is the whole point.
This article separates the layers.
System roles
The demonstration contains six functional roles:
| Role | Responsibility |
|---|---|
| Drone buyer | Receives payment requirements and signs EIP-3009 authorisations |
| eCandle seller | Supplies energy, measures usage, requests payment and collects proofs |
| Embedded key layer | Holds the secp256k1 key and performs local signing |
| Batch worker | Receives proof batches and forwards validated requests |
| HashAnchor | An x402 settle bridge. The seller never talks to Circle directly: the batch worker hands each batch to the @tlay/hashanchor-client SDK, which performs the flat→nested transform the payment path expects (value and nonce as strings via BigInt, x402Version: 2) and POSTs it to HashAnchor’s public /v1/x402/settle endpoint. HashAnchor forwards to Circle Gateway and returns the settlement result. The device never reshapes a signed proof. |
| Circle and Arc path | Settles the payment batch and supports a separate on-chain mint or transfer |
The physical buyer and seller communicate locally. The cloud worker and external services handle batch forwarding and settlement. The final Arc action is a separate state transition.
The BLE payment service
The service is 0xEE00 and uses six characteristics. MTU must be at least 512 — the 0xEE04 proof JSON is roughly 400 B and is written in one operation, with no ATT Long Write.
| UUID | Name | Direction | Purpose | Format |
|---|---|---|---|---|
0xEE01 | StreamRequest | Write, buyer→seller | Stream control: buyer opens or closes the session | 1 byte: 0x02 OPEN / 0x00 CLOSE |
0xEE02 | StreamOffer | Read/Notify, seller→buyer | Locked-rate quote — the payment requirements the buyer validates before opening the stream | boat_nano_stream_offer_t (50 B) |
0xEE03 | SliceRequest | Read/Notify, seller→buyer | Per-slice billing request, emitted every tick | boat_nano_slice_request_v2_t (88 B) |
0xEE04 | SlicePayment | Write, buyer→seller | Signed EIP-3009 authorisation returned by the buyer | flat JSON (~400 B), not a packed struct |
0xEE05 | StreamStatus | Read/Notify, seller→buyer | Stream status, measured output, session and heartbeat | boat_nano_stream_status_t (20 B) |
0xEE06 | SessionControl | Notify+Write, seller↔buyer | Optional operator and observer channel: START/STOP/PAUSE/RESUME/DONE downstream, buyer EOA acknowledgement upstream. Vestigial under self-drive | boat_nano_session_control_t (12 B) down, _ack_t (28 B) up |
The session begins when the buyer writes 0x02 to 0xEE01. The seller then publishes its StreamOffer on 0xEE02 and its current status on 0xEE05. Once the buyer accepts the offer against its local policy, the seller emits SliceRequests on 0xEE03 at the configured interval.
Note what drives the session: the buyer reads live AC power from 0xEE05 and opens or closes the stream itself. There is no cloud command in the loop.
Payment requirements before payment
The buyer should not sign an arbitrary request just because it arrived over BLE.
The StreamOffer carries the quote, and it is deliberately narrow — policy is injected by the seller application, not baked into the protocol. The seller fills it from boat_seller_config_t; v1 does not vary the quote per request. The 50 bytes are:
rate_usdc_per_kwh_micro price rule (the event build used $10/kWh)
slice_duration_ms timing (5000)
batch_size batching (6)
max_session_amount_usdc_micro session ceiling
expected_power_w_min / _max sanity band
payment_token[20] USDC contract on the settlement chain
reserved[8]Each SliceRequest then carries what binds that individual authorisation:
slice_id · valid_after · valid_before · nonce[32]
to_address[20] seller receiver wallet
amount_micro_usdc · wh_measured_micro · avg_power_mwSo the buyer’s decision is split across the two: the offer tells it the rate, cadence, token and session ceiling; each request tells it the recipient, the amount, the replay window and the measured physical quantity the amount is derived from.
Worth being precise about one thing the buyer does not see: the settlement route. A facilitator or settlement endpoint is never delivered over BLE and appears in neither struct — it is cloud-side configuration, and the device neither sees nor validates it. The device’s policy surface is smaller than the system’s.
The buyer’s own limits live in firmware, not in anything it receives. In the public release the hard per-session ceiling is DRONE_SESSION_CAP_MICRO = 1000000 (one dollar), alongside power thresholds (power_start_dw = 50, i.e. 5.0 W), the tick period and a liveness watchdog.
This is where a device can refuse an unsupported network, unexpected recipient or spending level. The public implementation should make that policy visible. A machine wallet without constraints is not autonomy; it is an unattended signing risk.
Path B: the drone signs each usage authorisation
For every usage interval, the eCandle creates a SliceRequest. The request binds the physical event to a payment context: who is selling, what amount is requested, which session it belongs to and how replay is prevented.
The amount is derived, not quoted. The seller averages the power samples accumulated since the last slice and bills that average:
avg_w = sum(samples) / count
wh_micro = avg_w * (slice_duration_ms/1000) * 1e6 / 3600
amount = round(wh_micro * rate_usdc_per_kwh_micro / 1e9) # micro-USDCBecause avg_w is an integer number of watts, the amount lands on a coarse grid. At five-second slices and the event rate, one watt is about 13.9 µUSDC, so adjacent achievable amounts differ by roughly 14 µ — 43 W bills 597 µ, 44 W bills 611 µ, and nothing in between exists. That quantisation is a useful integrity check: every settled value in the show archive lands on a grid point.
The drone converts the request into the EIP-3009 authorisation expected by the payment path. Its on-device secp256k1 key signs the typed data, and the firmware returns a SlicePayment through 0xEE04.
At minimum, the proof must allow the seller or facilitator to verify:
- the recovered buyer address;
- recipient;
- amount;
- token and network;
- nonce;
- validity window;
- signature domain;
- session binding.
The seller rejects malformed, expired, duplicate or policy-incompatible proofs.
One implementation detail matters for anyone reading the code: the seller forwards the proof JSON verbatim. It never reshapes it, because the EIP-712 signature covers the authorisation field values. Any normalisation happens in the SDK, after the device.
The critical output of this step is a signed authorisation. It is not an Arc transaction hash.
The domain that breaks everything
The single most common reason a settle fails is the EIP-712 domain. The buyer signs under the Circle Gateway domain, not the USDC token domain:
domain_name = "GatewayWalletBatched" // NOT "USDC"
domain_version = "1"
verifyingContract = the Circle Gateway address for the target chain
chain_id = 5042 (Arc mainnet) or 5042002 (Arc testnet)Get it wrong and the signatures still pass ecrecover — they recover some address, just not the one the settlement service expects. Settlement then rejects them with address_mismatch, which reads like a signing bug and is actually a configuration one. Because verifyingContract is part of the domain, pointing a build at the wrong chain’s Gateway address produces exactly the same symptom. Take both the Gateway addresses and the domain fields from Circle’s published reference for the chain you are actually on — the mainnet contracts are not the testnet ones.
Why the key remains on-device
The demonstration uses an embedded signing path so that the buyer’s private key does not need to be sent to the UI or cloud worker. On first flash, boat_crypto_init() generates a secp256k1 keypair and persists it in NVS through the BoAT MER PAL; the serial log prints only the resulting EOA address. A plain re-flash does not touch NVS, so the key and any balance tied to it survive.
That reduces one class of exposure, but it does not remove the need for a threat model.
A production design must answer:
- How is the key provisioned?
- Can firmware replacement change the recipient or price?
- What happens if the device is lost?
- How are spending limits enforced?
- Can a captured BLE message be replayed?
- How are clock drift and intermittent connectivity handled?
- Is the secure element resistant to physical extraction?
This reference implementation stores the key in NVS, and the firmware notes that NVS encryption should be enabled for production deployments. A public demo should describe exactly what hardware-backed protection it has and avoid implying stronger guarantees than have been tested.
Batching the proofs
The eCandle accumulates valid SlicePayment records.
In the tagged release, six five-second slices create one thirty-second batch — batch_size = 6 in the seller config, SETTLE_BATCH_MAX_AGE_MS = 30000 as the flush timer. Six slices at five seconds is thirty seconds, so the count and the timer coincide by construction; whichever arrives first flushes the batch. A session change also forces a flush.
A batch should include:
- batch identifier;
- session identifier;
- ordered proof list;
- proof count;
- total authorised value;
- creation timestamp;
- seller or facilitator metadata;
- integrity protection over the batch.
The eCandle publishes the batch to MQTT on ecandle//settle. A cloud worker validates its structure and forwards it through HashAnchor’s public /v1/x402/settle endpoint, which relays it to Circle Gateway’s batched x402 settlement path. The settle endpoint requires no API key.
The settlement response returns a Circle transfer UUID. From the September 17 archive, the first payment of the show:
id / transactionId 4ea19fa1-6c34-4d39-927c-99b2da17aa90
payer 0x39c807397d12ad8914200342c17b89d0b3817748
payTo 0xfc546bf1b2e315ffaf5e40778577df5b7fa78482
value 944 µUSDC (68 W over five seconds)
network eip155:5042 (Arc mainnet)
timestamp 2026-09-18T04:30:16.857ZThat UUID identifies the Circle-side operation. It is not an Arc transaction hash and should never be linked to a block explorer.
Path A: the seller signs the Arc intent
The second signature belongs to a different path.
After enough value has cleared on the seller side, an operator can trigger “Mint to Arc.” The eCandle signs an EIP-712 BurnIntent using the seller identity. The cloud worker posts that intent to Circle’s /v1/transfer endpoint to obtain an attestation, and the operator wallet then calls GatewayMinter.gatewayMint(...), moving the seller’s cleared value into on-chain USDC on Arc mainnet (chainId 5042). The public release defaults to Arc testnet (5042002); the September 17 show ran on mainnet.
Continuing the same record through this layer:
burnIntentDigest 0xee1949fb6002943425398034cb566624d8a605602dd92095d1c430880ece5eed
onChainTxHash 0xf510d9e9c951088cb1d7dc78d7f1bd30ffed68d2f2adf00cc042df32b0e2f0f8
mintBlockNumber 21445811
attestationStatus mintedThis step returns an Arc transaction hash. In the show it took roughly 7 to 26 seconds end to end.
| Property | Per-slice payment path | Mint-to-Arc path |
|---|---|---|
| Signer | Drone buyer | eCandle seller |
| Signature | EIP-3009 | EIP-712 BurnIntent |
| Cadence | Every usage tick (5 s) | On demand |
| Immediate result | Signed payment authorisation | Request to move cleared value on-chain |
| Service result | Circle settlement UUID | Arc transaction hash |
| Arc gas | No separate Arc transaction per slice | Paid by a dedicated operator wallet, not by either device. On Arc the gas token is USDC-denominated; the measured cost was about 0.0027 USDC per mint. The operator key is read from a file, never from an environment variable, and neither the buyer nor the seller can spend it. |
| September 17 show | 927 authorisations | 12 transactions |
The two paths are related, but they are not interchangeable.
Failure states are part of the protocol
A physical machine-payment system must be explicit about what happens when the happy path breaks.
BLE disconnects before proof delivery
The seller should not treat an unsigned or incomplete slice as paid. Reconnection must not duplicate the nonce.
The same proof arrives twice
The validation layer must detect the duplicate and preserve idempotency.
MQTT is unavailable
The seller needs a bounded queue and a policy for pausing service when unsettled exposure exceeds a limit. This is a real edge, not a hypothetical one: an outbox that silently discards a signed proof after a publish timeout loses money without reporting an error, because the seller believes it sent.
Settlement rejects a batch
The system should preserve the original proofs, expose a useful error and avoid creating a second economically equivalent batch without clear idempotency.
The device signature times out
Observed six times across two days of running: the mint request waits on the seller’s signature and gives up. All six retries succeeded, one of them signing back in a single second. Nothing is charged and nothing reaches the chain. The cause is still unknown — two plausible explanations, a weak uplink and signer contention, were each tested against the data and neither held. The operational answer is a retry, not a mechanism.
The Arc mint fails
The Circle-side settled state and the Arc-side mint state must remain distinct. A mint failure should not rewrite settled usage history.
What the proof page should expose
The best way to evaluate the demonstration is to connect one record from every layer. The archive makes that chain verifiable for all 927 payments; a single row looks like this:
| Layer | Value | On-chain? |
|---|---|---|
| SliceRequest | 944 µUSDC, derived from 68 W over five seconds | no |
| Signed SlicePayment | EIP-3009 authorisation, buyer 0x39c807…7748 | no |
| Batch | up to six proofs, flushed at 30 s | no |
| Circle settlement UUID | 4ea19fa1-6c34-4d39-927c-99b2da17aa90 | no |
| Seller BurnIntent | 0xee1949fb…5eed | no |
| Arc transaction | 0xf510d9e9…f0f8, block 21445811 | yes |
The page should decode the public fields and explain which objects are off-chain and which one represents the on-chain transaction. Exactly one row in that table is a chain transaction; five are not.
You do not have to take a block explorer’s word for the last row. Both Arc explorers are single-page applications, so the transaction detail is fetched by JavaScript after load; the check that depends on no front end is the RPC itself:
curl -s -X POST https://rpc.mainnet.arc.io \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_getTransactionReceipt","params":
["0xf510d9e9c951088cb1d7dc78d7f1bd30ffed68d2f2adf00cc042df32b0e2f0f8"],"id":1}'That endpoint answers 0x13b2 to eth_chainId, which is 5042, and returns this receipt:
status 0x1 (success)
blockNumber 21445811 (matches the archive)
to 0x2222222d7164433c4c09b0b0d809a9b52c04c205 (GatewayMinter, Arc mainnet)
gasUsed 133203
logs 4That to address is worth noticing on its own: the mainnet GatewayMinter is not the testnet one. It is the domain trap described earlier, in concrete form.
A reusable primitive set
The demonstration is not valuable because drones are uniquely important payment users. It is valuable because the protocol boundary is reusable.
Replace energy with another measurable resource. Replace the drone with another buyer. Keep the same separation between local measurement, constrained device policy, cryptographic authorisation, efficient settlement and optional on-chain finality.
The seller side is the clearest illustration of that separation: rate, slice cadence, batch size, session ceiling, power band and token are all injected through boat_seller_config_t. The protocol hard-codes none of them. Swapping the meter input and the unit rule is most of the work of retargeting it.
That is the architecture we want other builders to inspect, challenge and extend.
- Source code — arc-drone-show-demo on GitHub, release tag
v1.0.0 - Live demonstration — the drone session, running
- Arc transaction —
0xf510d9e9…f0f8on Arc Explorer - Builder guide — From git clone to a two-device machine-payment flow
- What happened at the showcase — When the drone paid for power