duly

main @ b3afb1b155ca · 26 commits since 2026-11-01 12:05 UTC · last 2026-11-12 15:39 UTC
git clone https://code.swiftbotlab.workers.dev/duly.git

Read-only mirror, served as static files. Full history is in the clone. refs

73 files
.github/workflows/ci.yml2,367 B.gitignore76 BCargo.lock69,270 BCargo.toml180 BLICENSE1,071 BREADME.md23,085 BSECURITY.md3,848 Bcontract/Cargo.toml422 Bcontract/src/lib.rs32,552 Bcontract/src/tests.rs35,066 Bdocs/duly-demo.mp413,481,363 Bdocs/paid-invoice.png133,733 Bevidence/mainnet.js5,888 Bevidence/mainnet.md3,143 Bevidence/testnet.md1,342 Bmock-usdc/Cargo.toml300 Bmock-usdc/src/lib.rs3,951 Bpackage.json412 Brust-toolchain.toml164 Bscripts/chain.mjs5,266 Bscripts/check.sh2,844 Bscripts/demo-agent.mjs4,786 Bscripts/demo-testnet.mjs9,165 Bscripts/issue-invoice.mjs7,586 Bscripts/keygen.mjs979 Bscripts/mainnet-setup.sh2,183 Bscripts/net.mjs868 Bscripts/positive-controls.sh4,517 Bscripts/reproduce.sh2,233 Bscripts/secret-scan.sh1,711 Bscripts/verify-mainnet.mjs9,894 Btest/agent.test.mjs2,611 Btest/chains.test.mjs1,916 Btest/doc.test.mjs2,996 Btest/fixtures/1click-dry-quote.json1,496 Btest/money.test.mjs2,890 Btest/near.test.mjs5,147 Btest/pages.test.mjs8,877 Btest/policy.test.mjs8,924 Btest/quote.test.mjs9,447 Btest/session.test.mjs3,097 Bweb/_headers579 Bweb/api.js3,509 Bweb/app.js9,123 Bweb/boot.js1,040 Bweb/fonts/LICENSE-Archivo.txt4,503 Bweb/fonts/LICENSE-JetBrainsMono.txt4,314 Bweb/fonts/archivo-var.woff290,104 Bweb/fonts/jbmono-400.woff221,168 Bweb/fonts/jbmono-500.woff221,832 Bweb/icon.svg287 Bweb/index.html2,013 Bweb/lib/chains.js3,083 Bweb/lib/config.js1,473 Bweb/lib/csv.js2,414 Bweb/lib/doc.js2,269 Bweb/lib/money.js2,091 Bweb/lib/near.js11,365 Bweb/lib/quote.js8,074 Bweb/lib/session.js5,037 Bweb/merchant.js32,086 Bweb/pay.js17,490 Bweb/style.css65,640 Bweb/ui.js4,731 Bweb/vendor/ed25519.js39,841 Bweb/vendor/qrcode.js56,694 Bworker/src/agent.js7,385 Bworker/src/index.js9,154 Bworker/src/keys.js1,142 Bworker/src/ledger.js26,708 Bworker/src/pages.js38,290 Bworker/src/policy.js10,653 Bworker/wrangler.toml1,037 B

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.

A paid invoice on a phone

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

  1. You create an invoice and share a link.
  2. Your client pays with any major token on any major chain, from a wallet or an exchange.
  3. 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.
  4. 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.
  5. You download a CSV your accountant can open.

For a bookkeeper

You want to knowWhere 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 happenedNEARBase
A client pays 1.00 USDC for invoice 1-3 from Base, through the pay page0x0429a3f1…
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 blockGceCNYM3…
The merchant withdraws 0.10 USDC to Base. The relayer pays the gas and the contract starts the payout; 0.097579 USDC lands8W9cfQoL…0x11374a0b…
The merchant refunds 0.05 USDC of invoice 1-1 to the address recorded at payment; 0.047588 USDC lands2mq3WzG1…0xcd22d014…
Invoice 1-1, 0.25 USDC, paid from Base by script; 0.05 set aside3dgog4LY…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).

DoSee
Open https://duly.swiftbotlab.workers.dev/test/app and press StartA dashboard. No wallet prompt and no passkey: test mode makes a throwaway account
Press New invoice, fill it in, press Issue invoice and get linkA pay link and QR code
Open Settings, keep the Tax 20% row, press SaveThe rule is saved on-chain, gas paid by the relayer
Open the invoice, press Open as client, then Simulate the client payingA PAID stamp and a settlement transaction link
Open MoneyYour balance and the Tax jar, split in the same transaction
Press Download CSVOne 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.

DoSee
Open https://duly.swiftbotlab.workers.dev/i/1-4Invoice 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 priceA 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 amountWaiting, 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
StepCommand it runsPasses whenTime
JS unit tests: relayer allowlist, fingerprint, quote checks, money, CSV, sessions, agent, server-rendered pagesnode --test test/*.test.mjs# fail 0 (109 tests)2 s
Contract unit testscargo test -p duly-contract --lockedtest result: ok. 74 passed2 to 5 min the first time
Positive controls: each guard is broken on purpose in a copy of the repo, and its test must failscripts/positive-controls.shAll 5 controls passed5 s
Mainnet evidence from public RPCsnode scripts/verify-mainnet.mjsAll 25 checks passed.15 s
Full paid-invoice loop on testnetnode scripts/demo-testnet.mjsAll 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

ClaimCodeTest that fails without it
The payment closes the invoice. No server decides what is paidft_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 receiptlib.rs#L581-L594: each share is credited from the applied amount before the merchant's net; an overpayment is never splitset_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 contractThe 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 anythingtest/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-chainweb/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 ittest/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 refusedThe 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 paymentThe browser calls 1Click directly and verifies the quote signature and every signed field: web/lib/quote.jstest/quote.test.mjs
The collections agent cannot move money anywhere elseIts 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 attachagent_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

PieceLive (mainnet)Test mode (testnet)
Contract: invoices, set-asides, part payments, overpayments, refundsReal: dulypay.nearReal: dulypay.testnet
Gas sponsorship (NEP-366 meta transactions)RealReal
Client payment from another chainReal, through the NEAR Intents 1Click APISimulated: the Simulate button sends test USDC through the real contract
Payouts and refunds to other chainsReal, started by the contract through NEAR IntentsNEAR accounts only
Subcontractor share at settlementSame code; the mainnet run used a 20% tax share onlyReal: 20% tax and 10% subcontractor in one transaction
Passkey sign-inReal WebAuthn with PRF; tested in Chrome with a virtual authenticator, not on a physical phoneNot used: a throwaway key
Collections agentPlain rules on a timer, with no language modelSame
Encrypted documentsStored by the Worker as ciphertextSame

How NEAR is used

NEAR featureWhat it does here
NEAR Intents, 1Click APIConverts 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 accountsAn 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 USDCThe settlement token.
Function-call keysThe 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

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 canIt cannot
Notice an overdue invoice and send the merchant a reminder to forwardWithdraw, refund or send money anywhere else
Offer a discount for quick payment, up to the cap the merchant set on-chainExceed that cap, or touch an invoice that is not overdue
Sweep idle money above a floor to the one account the merchant namedChoose 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

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

LibraryUseLicense
near-sdk 5.29.1ContractMIT or Apache-2.0
near-contract-standards 5.29.1Test token onlyMIT or Apache-2.0
@noble/ed25519 3.2.0Browser signing, vendored in web/vendorMIT
qrcode-generator 2.0.4QR codes, vendored in web/vendorMIT
Cloudflare Workers and wranglerHosting: Worker, Durable Object, static files
Node.js built-in test runnerJS tests; no npm dependencies
NEAR Intents 1Click API, public NEAR and Base RPCsConversion, chain reads

Prior work and disclosures

License

MIT. See LICENSE.