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:

RoleResponsibility
Drone buyerReceives payment requirements and signs EIP-3009 authorisations
eCandle sellerSupplies energy, measures usage, requests payment and collects proofs
Embedded key layerHolds the secp256k1 key and performs local signing
Batch workerReceives proof batches and forwards validated requests
HashAnchorAn 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 pathSettles 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.

UUIDNameDirectionPurposeFormat
0xEE01StreamRequestWrite, buyer→sellerStream control: buyer opens or closes the session1 byte: 0x02 OPEN / 0x00 CLOSE
0xEE02StreamOfferRead/Notify, seller→buyerLocked-rate quote — the payment requirements the buyer validates before opening the streamboat_nano_stream_offer_t (50 B)
0xEE03SliceRequestRead/Notify, seller→buyerPer-slice billing request, emitted every tickboat_nano_slice_request_v2_t (88 B)
0xEE04SlicePaymentWrite, buyer→sellerSigned EIP-3009 authorisation returned by the buyerflat JSON (~400 B), not a packed struct
0xEE05StreamStatusRead/Notify, seller→buyerStream status, measured output, session and heartbeatboat_nano_stream_status_t (20 B)
0xEE06SessionControlNotify+Write, seller↔buyerOptional operator and observer channel: START/STOP/PAUSE/RESUME/DONE downstream, buyer EOA acknowledgement upstream. Vestigial under self-driveboat_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_mw

So 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-USDC

Because 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 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:

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:

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.857Z

That 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 minted

This step returns an Arc transaction hash. In the show it took roughly 7 to 26 seconds end to end.

PropertyPer-slice payment pathMint-to-Arc path
SignerDrone buyereCandle seller
SignatureEIP-3009EIP-712 BurnIntent
CadenceEvery usage tick (5 s)On demand
Immediate resultSigned payment authorisationRequest to move cleared value on-chain
Service resultCircle settlement UUIDArc transaction hash
Arc gasNo separate Arc transaction per slicePaid 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 show927 authorisations12 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:

LayerValueOn-chain?
SliceRequest944 µUSDC, derived from 68 W over five secondsno
Signed SlicePaymentEIP-3009 authorisation, buyer 0x39c807…7748no
Batchup to six proofs, flushed at 30 sno
Circle settlement UUID4ea19fa1-6c34-4d39-927c-99b2da17aa90no
Seller BurnIntent0xee1949fb…5eedno
Arc transaction0xf510d9e9…f0f8, block 21445811yes

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            4

That 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.