Duly
Pay-by-link invoicing for freelancers and small businesses. A client pays with whatever token they hold, on whatever chain. The business receives USDC on NEAR, without a wallet app and without buying NEAR for gas. The payment itself closes the invoice on-chain, and in that same step the contract moves the tax share and any subcontractor's share into their own balances.
- Hosted: https://duly.swiftbotlab.workers.dev (test mode with worthless test money: https://duly.swiftbotlab.workers.dev/test/app). The home page and every invoice page read without JavaScript.
- Live on NEAR mainnet: contract
dulypay.near, code hash7jt6GbQnA7VyUGs65Azim73ypuZbBZa2GSKfXHkMQqvj. A real 1.00 USDC payment from Base (0x0429a3f1…) closed its invoice on-chain in the contract (GceCNYM3…) 31.8 seconds after the Base block: no server decides that an invoice is paid. All transactions below. - Track: Real-World Money (
realworld). Built at NEAR HACKATHON. - Demo video (2:17, narrated): docs/duly-demo.mp4: the 1.00 USDC payment from Base closing its invoice on mainnet, then test mode with a tax share and a subcontractor share split in one transaction, the CSV, and the verifier.

Important
The contract is not audited. Sponsored live invoices run from 0.50 to 25 USDC on purpose. An upgrade key is kept on the contract account during the hackathon, so "no admin" is not claimed. See SECURITY.md.
The problem
A freelancer whose client holds USDT on Tron and who wants USDC has three bad choices: ask the client to bridge, take the wrong token, or wait for a wire. When the money does arrive, three chores remain: working out which invoice it paid, remembering that part of it belongs to the tax office or a subcontractor, and giving the accountant something they can read.
What Duly does
- You create an invoice and share a link.
- Your client pays with any major token on any major chain, from a wallet or an exchange.
- NEAR Intents converts the payment to USDC. The USDC reaches our contract with the invoice number attached, so the payment itself marks the invoice paid.
- In that same step, your set-asides move into separate balances: for example 20% for tax and 10% to a subcontractor. That money leaves your spending balance before you could spend it by accident.
- You download a CSV your accountant can open.
For a bookkeeper
| You want to know | Where to find it |
|---|---|
| Is this invoice paid? | Its status: Not paid yet, Part paid, Paid, Overdue, Part refunded, Refunded or Cancelled. It is read from the contract, not from our server. |
| Which transaction paid it? | Each invoice links to its settlement transaction on the NEAR explorer, and to the payment on the client's chain when we saw it. |
| Were the numbers changed after issue? | Invoice numbers run 1, 2, 3 per business with no gaps and are issued by the contract with a timestamp. A fingerprint of the invoice document is stored on-chain when it is issued and can never be changed. The pay page refuses a document that does not match it. |
| How much tax was set aside? | The Set aside column, per invoice. |
| What did the client pay extra, or get back? | The Extra paid by client and Refunded columns. |
CSV columns: Invoice number, Client, Issued, Due, Status, Currency, Invoice total, Tax included, Discount given, Received, Date received, Set aside, Kept in balance, Refunded, Extra paid by client, Shown to client in, Exchange rate, Total in that currency, Paid from, Transaction link.
Proof on NEAR mainnet
Real money on 2026-11-01. Amounts were kept tiny and all of it was ours. Contract dulypay.near, code hash 7jt6GbQnA7VyUGs65Azim73ypuZbBZa2GSKfXHkMQqvj: the SHA-256 of the wasm built in CI from commit 43cc462 with cargo-near 0.22.0 and Rust 1.95.0. The contract code at the tip of this repository is the same; only the repository URL in contract/Cargo.toml has changed since, so the deployed wasm's NEP-330 metadata (contract_source_metadata) still names the old host, github.com/swiftbotlab/duly.
| What happened | NEAR | Base |
|---|---|---|
A client pays 1.00 USDC for invoice 1-3 from Base, through the pay page | 0x0429a3f1… | |
NEAR Intents delivers 1.00 USDC by ft_transfer_call with msg = "1-3|…". ft_on_transfer closes the invoice and moves 0.20 USDC to the tax set-aside in the same receipt, 31.8 s after the Base block | GceCNYM3… | |
| The merchant withdraws 0.10 USDC to Base. The relayer pays the gas and the contract starts the payout; 0.097579 USDC lands | 8W9cfQoL… | 0x11374a0b… |
The merchant refunds 0.05 USDC of invoice 1-1 to the address recorded at payment; 0.047588 USDC lands | 2mq3WzG1… | 0xcd22d014… |
Invoice 1-1, 0.25 USDC, paid from Base by script; 0.05 set aside | 3dgog4LY… | 0x9f6b846b… |
The merchant account was created by the relayer, holds 1 yoctoNEAR (10^-24 NEAR) and never bought gas. Every transaction of the run, including registration, the tax share, the three invoices and the deployment, is listed in evidence/mainnet.md. The 15 that carry the claims (11 on NEAR, 4 on Base) are in evidence/mainnet.js as data, which the verifier below re-checks. The 0.0024 USDC gaps are the NEAR Intents withdrawal fee.
Check it without trusting us (Node 22, no keys, about 15 seconds):
node scripts/verify-mainnet.mjs
See: 25 PASS lines ending in All 25 checks passed. It asks public NEAR archival RPCs and Base RPCs, not our server, for: the code hash; that the merchant holds no NEAR; that the agent's key is limited to two methods; every transaction's signer, status and inner method; the invoice_payment event with closed: true and the set-aside inside the settlement receipt; each USDC transfer on Base; today's invoice state; the 31.8 s; and that the contract holds at least what it owes. nearblocks.io and basescan.org block some automated readers; the same transactions are on api.nearblocks.io (JSON) and Blockscout.
Try it
No account, wallet, passkey or funds are needed for A, C and D.
A. Click through it in a browser, on testnet (about two minutes).
| Do | See |
|---|---|
| Open https://duly.swiftbotlab.workers.dev/test/app and press Start | A dashboard. No wallet prompt and no passkey: test mode makes a throwaway account |
| Press New invoice, fill it in, press Issue invoice and get link | A pay link and QR code |
| Open Settings, keep the Tax 20% row, press Save | The rule is saved on-chain, gas paid by the relayer |
| Open the invoice, press Open as client, then Simulate the client paying | A PAID stamp and a settlement transaction link |
| Open Money | Your balance and the Tax jar, split in the same transaction |
| Press Download CSV | One readable row per invoice |
Test mode uses the real contract on NEAR testnet with worthless test USDC. Only the cross-chain conversion is simulated, because NEAR Intents has no testnet.
B. Pay a real invoice with about 10 cents (mainnet, optional). Invoice [1-4](https://duly.swiftbotlab.workers.dev/i/1-4) is open for 0.10 USDC, due 2026-11-30.
| Do | See |
|---|---|
| Open https://duly.swiftbotlab.workers.dev/i/1-4 | Invoice 0004, 0.10 USDC, Not paid yet, due 2026-11-30, read from dulypay.near |
| Pick USDC on Arbitrum, Optimism, Polygon, Gnosis or NEAR, enter your own address for refunds, press See the price | A price just over 0.10 USDC from NEAR Intents (0.1010 from Arbitrum on 2026-11-10). On Base and Ethereum, 0.10 is below the route minimum |
| Press Get payment address and send the exact amount | Waiting, Payment seen, Converting, Invoice paid. The invoice closes on-chain by itself |
The page checks the 1Click quote's signature and every signed field before it shows an address. The first payment closes the invoice; anything paid after that is recorded as extra and stays refundable.
C. See a paid invoice. Open https://duly.swiftbotlab.workers.dev/i/1-3. See: a PAID stamp, 1.00 USDC received, 0.20 set aside, and links to the settlement on NEAR and the payment on Base. Or ask the chain:
curl -s https://free.rpc.fastnear.com -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"query","params":{"request_type":"call_function","finality":"final","account_id":"dulypay.near","method_name":"get_invoice","args_base64":"eyJpZCI6IjEtMyJ9"}}' \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const i=JSON.parse(Buffer.from(JSON.parse(s).result.result).toString());console.log(i.status,"paid",i.paid,"set aside",i.set_aside)})'
See: paid paid 1000000 set aside 200000, which is 1.00 USDC received and 0.20 set aside in the same step.
D. Run the whole loop from a terminal (about one minute).
node scripts/demo-testnet.mjs
See: real NEAR testnet transactions, each printed with an explorer link, ending in All checks passed. It creates a merchant with no wallet and no NEAR, sets a 20% tax jar and a 10% subcontractor share, issues a 100 USDC invoice, pays 40 then 60, checks that the closing payment of 60 was split in its own transaction (12 to the tax jar, 6 to the subcontractor), that the tax jar then holds 20 and the merchant 70, and that the invoice records 30 set aside, refunds an overpayment, attempts to drain the relayer (refused) and prints the CSV row.
Reproduce everything from a clean clone
One command runs every test and check. It needs Node 22 or later, Rust through rustup (rust-toolchain.toml picks the version) and internet access. There is no install step: the JS has no dependencies.
git clone https://code.swiftbotlab.workers.dev/duly.git && cd duly
npm run reproduce # or, with Node only and no Rust: npm run reproduce -- --quick
| Step | Command it runs | Passes when | Time |
|---|---|---|---|
| JS unit tests: relayer allowlist, fingerprint, quote checks, money, CSV, sessions, agent, server-rendered pages | node --test test/*.test.mjs | # fail 0 (109 tests) | 2 s |
| Contract unit tests | cargo test -p duly-contract --locked | test result: ok. 74 passed | 2 to 5 min the first time |
| Positive controls: each guard is broken on purpose in a copy of the repo, and its test must fail | scripts/positive-controls.sh | All 5 controls passed | 5 s |
| Mainnet evidence from public RPCs | node scripts/verify-mainnet.mjs | All 25 checks passed. | 15 s |
| Full paid-invoice loop on testnet | node scripts/demo-testnet.mjs | All checks passed. | 1 min |
It ends with a summary, one line per step, and exits non-zero if anything failed. scripts/positive-controls.sh --network --rust adds two tampered-evidence controls for the verifier and one for the contract. To build the contract wasm: cd contract && cargo near build non-reproducible-wasm --locked (needs cargo-near 0.22.0).
Where the guarantees are in the code
| Claim | Code | Test that fails without it |
|---|---|---|
| The payment closes the invoice. No server decides what is paid | ft_on_transfer in contract/src/lib.rs#L548: only the USDC contract may call it (L549); msg carries the invoice id; the invoice closes when the applied amount reaches what is due (L573) | exact_payment_closes_invoice_in_the_same_call, a_direct_call_cannot_fake_a_payment, other_tokens_are_refused in contract/src/tests.rs |
| Set-asides move at settlement, in the same receipt | lib.rs#L581-L594: each share is credited from the applied amount before the merchant's net; an overpayment is never split | set_asides_happen_in_the_settlement_step, overpayment_is_kept_apart_from_set_asides, balances_always_add_up |
| The relayer pays only for an allowlist of methods on our own contract | The allowlist is METHODS in worker/src/policy.js#L14-L26, with a gas cap, a pinned deposit and a daily count per method. L179 refuses any receiver except the Duly contract, L181 any method not in the list, L184 any other deposit. worker/src/ledger.js#L242-L249 runs these checks before the relayer signs anything | test/policy.test.mjs: calls to any other contract are refused, methods outside the list are refused…, the relayer cannot be made to attach more NEAR…. Positive controls 1 to 3 break each of these lines and the tests fail |
| Line items are encrypted in the browser; only a fingerprint goes on-chain | web/lib/doc.js#L29-L38 seals the document with AES-GCM and returns hash, the SHA-256 of the ciphertext. create_invoice stores it once (lib.rs#L406, checked at L420, kept at L119); no method changes it | test/doc.test.mjs, document_fingerprint_is_fixed_when_the_invoice_is_issued, document_fingerprint_must_be_64_lowercase_hex |
| A document changed after issue is refused | The pay page compares the fingerprint before decrypting (web/lib/doc.js#L43); the Worker refuses to store a mismatching document (worker/src/ledger.js#L328) | a document changed after issue is refused, even when it decrypts, a single flipped byte is refused. Positive control 4 removes the check and these fail |
| A compromised Duly server cannot redirect a payment | The browser calls 1Click directly and verifies the quote signature and every signed field: web/lib/quote.js | test/quote.test.mjs |
| The collections agent cannot move money anywhere else | Its key is a function-call key for offer_discount and agent_sweep only (checked on mainnet by verify-mainnet.mjs); withdraw, refund and set_agent need 1 yoctoNEAR, which a function-call key cannot attach | agent_limits_cannot_be_changed_by_a_function_call_key, agent_cannot_exceed_the_cap, agent_sweep_goes_only_to_the_named_account_above_the_floor |
What is real and what is not
| Piece | Live (mainnet) | Test mode (testnet) |
|---|---|---|
| Contract: invoices, set-asides, part payments, overpayments, refunds | Real: dulypay.near | Real: dulypay.testnet |
| Gas sponsorship (NEP-366 meta transactions) | Real | Real |
| Client payment from another chain | Real, through the NEAR Intents 1Click API | Simulated: the Simulate button sends test USDC through the real contract |
| Payouts and refunds to other chains | Real, started by the contract through NEAR Intents | NEAR accounts only |
| Subcontractor share at settlement | Same code; the mainnet run used a 20% tax share only | Real: 20% tax and 10% subcontractor in one transaction |
| Passkey sign-in | Real WebAuthn with PRF; tested in Chrome with a virtual authenticator, not on a physical phone | Not used: a throwaway key |
| Collections agent | Plain rules on a timer, with no language model | Same |
| Encrypted documents | Stored by the Worker as ciphertext | Same |
How NEAR is used
| NEAR feature | What it does here |
|---|---|
| NEAR Intents, 1Click API | Converts the client's payment to USDC (EXACT_OUTPUT, so the business receives the exact invoice amount). The browser calls 1Click directly and checks the quote's signature and every signed field before showing an address. |
**customRecipientMsg to ft_on_transfer** | The USDC arrives by ft_transfer_call with the invoice number in msg. The contract closes the invoice in that call. |
| Meta transactions (NEP-366) | Businesses sign actions and our relayer pays gas and storage, only for the allowlist above. |
| Implicit accounts | An account is derived from a passkey, or from a recovery key. There is no wallet to install and no seed phrase at sign-up. |
| NEP-141 USDC | The settlement token. |
| Function-call keys | The collections agent's key is limited to two methods on our contract. |
Architecture
flowchart LR
C[Client wallet, any chain] -->|deposit address| I[NEAR Intents 1Click]
I -->|USDC, ft_transfer_call, msg = invoice id| K[Duly contract]
K --> B[Merchant balance]
K --> T[Tax jar]
K --> S[Subcontractor balance]
M[Merchant browser] -->|signed delegate action| R[Relayer, Cloudflare Worker]
R -->|pays gas| K
M -->|ciphertext| D[(Encrypted documents)]
C -->|reads invoice, verifies quote| K
contract/: Rust settlement contract. See SECURITY.md for who can do what.worker/: relayer, encrypted document store, payment notices, the collections agent, and the server-rendered home and invoice pages.web/: the app, with no build step.evidence/: the mainnet and testnet runs, with every transaction.mock-usdc/: a worthless test token, used on testnet only.
Collections agent
The agent is plain rules on a timer, with no language model. It works from public chain state, so it never sees client names. node scripts/demo-agent.mjs (about three minutes) shows it on testnet: a merchant sets limits on-chain (at most 3% off, sweep to one named account, keep 2 USDC) and then asks for 10% in the agent's off-chain rule; an invoice goes overdue and the agent offers 3%, because the contract's cap wins; after the client pays, it sweeps the balance down to the floor.
| It can | It cannot |
|---|---|
| Notice an overdue invoice and send the merchant a reminder to forward | Withdraw, refund or send money anywhere else |
| Offer a discount for quick payment, up to the cap the merchant set on-chain | Exceed that cap, or touch an invoice that is not overdue |
| Sweep idle money above a floor to the one account the merchant named | Choose the destination or change the floor |
What it costs to run
Measured on mainnet: an invoice costs the relayer about 0.0063 NEAR, roughly $0.03, and the merchant nothing. Duly charges no fee today. A 0.1% fee would cover that on any invoice above about $30.
Limits
- The contract is unaudited. Sponsored live invoices run from 0.50 to 25 USDC for now: the app refuses less, because NEAR Intents will not convert much less than 0.15 USDC on some routes and 0.50 clears every route we offer. The public samples
1-1(0.25) and1-4(0.10) were issued by script below that floor on purpose, so anyone can try a real payment for cents. A payment that small converts only where the route minimum is lower, so1-4takes USDC on Arbitrum, Optimism, Polygon, Gnosis or NEAR, and not on Base or Ethereum; the headline Base payment is invoice1-3, 1.00 USDC. - An upgrade key is kept on the contract account during the hackathon, so "no admin" is not claimed. The agent account also has a full-access key, kept offline; the Worker holds only the function-call key.
- Passkey sign-up was tested in Chrome with a virtual authenticator that supports PRF, not on a physical phone.
- The wasm is built with
non-reproducible-wasm, so the code hash is not claimed to be rebuildable byte for byte on another machine. The deployed contract'scontract_source_metadatalink still points at the old source host; it changes with the next deploy. - A failed payout returns to the merchant's account on NEAR Intents, and moving it from there needs custom tooling. See SECURITY.md.
- A refund comes out of the merchant's available balance. A share already paid to a subcontractor is not pulled back.
- Duly stops at USDC. Getting to a bank account means withdrawing to an exchange or off-ramp the business already uses.
- A client's refund address is typed or taken from their wallet. If they pay from an exchange, it must be an address they control.
- The relayer's key is a full-access key, because NEP-366 wrappers cannot be sent with a function-call key. Its balance is kept small and its daily spend is capped.
AI tools
The code, tests, scripts and docs were written with Claude Code (Anthropic's coding assistant) under our direction. The product, the contract design, the security limits and every mainnet run are ours, and the claims rest on checks anyone can run: npm run reproduce (109 JS and 74 contract tests) and node scripts/verify-mainnet.mjs (25 checks against public RPCs). Duly calls no AI model at runtime; the collections agent is plain rules on a timer.
Libraries
| Library | Use | License |
|---|---|---|
| near-sdk 5.29.1 | Contract | MIT or Apache-2.0 |
| near-contract-standards 5.29.1 | Test token only | MIT or Apache-2.0 |
| @noble/ed25519 3.2.0 | Browser signing, vendored in web/vendor | MIT |
| qrcode-generator 2.0.4 | QR codes, vendored in web/vendor | MIT |
| Cloudflare Workers and wrangler | Hosting: Worker, Durable Object, static files | |
| Node.js built-in test runner | JS tests; no npm dependencies | |
| NEAR Intents 1Click API, public NEAR and Base RPCs | Conversion, chain reads |
Prior work and disclosures
- Baseline (Q-001, Q-002, Q-005): none of this code predates the hackathon. The first commit is
772037con 2026-11-01 at 12:05 UTC, and every commit since is part of the submission. There is no earlier product on another chain. The files inweb/vendorare unmodified releases of the third-party libraries above; nothing else is reused. - Second project (Q-015): this team's second project is near-toll-audit. The two are independent and share no code.
License
MIT. See LICENSE.