Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ soroban-sdk = "21.7.0"

[dev-dependencies]
soroban-sdk = { version = "21.7.0", features = ["testutils"] }
# Used only by tests to produce real Ed25519 signatures for the gasless
# assignment path; already in the lock graph via soroban-sdk testutils.
ed25519-dalek = "2"

[features]
default = []
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This repository contains the Soroban smart contract powering the LatterFix TaskM
| **Access Control** | `access_control.rs` | Role-based access control (RBAC) system |
| **Escrow** | `escrow.rs` | Milestone-based payment escrow management |
| **Events** | `events.rs` | Standardized event emission for off-chain indexing |
| **Gasless** | `gasless.rs` | Ed25519 signature-based gasless task assignment via a relayer |
| **Governance** | `governance.rs` | Proposal and voting system for protocol decisions |
| **Pausable** | `pausable.rs` | Emergency pause functionality per action type |
| **Reputation** | `reputation.rs` | User reputation and tier system |
Expand Down
99 changes: 99 additions & 0 deletions pr-077.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# feat(gasless): Ed25519 signature-based gasless task assignment (#077)

Closes #77

## Summary

Adds a **gasless** path for claiming an open task. A contributor signs a
structured authorization message **off-chain** with their Ed25519 key; a **gas
relayer** submits it on-chain and pays the fees. The contract re-derives the
exact signed bytes from the call arguments, verifies the signature against the
contributor's registered key, and enforces a **per-contributor nonce** so a
captured message can never be replayed.

Today `assign_task` (`src/lib.rs`) requires `assignee.require_auth()` — the
contributor must hold XLM and submit the transaction themselves. This PR keeps
that path untouched and adds `assign_task_gasless` alongside it.

## How it works

1. **Key registration (once).** The contributor registers an Ed25519 public key,
either themselves via `register_signing_key` or through an admin via
`admin_set_signing_key` (so onboarding can itself be gasless).
2. **Off-chain signing.** The contributor's client builds a `GaslessAssignment`:

```json
{
"contract": "C...", // this contract (domain separation)
"task_id": 42,
"contributor": "G...", // address to be assigned
"nonce": 0, // == current on-chain nonce for contributor
"expiration_ledger": 1234567 // request void once ledger passes this
}
```

It fetches the canonical bytes from `gasless_assignment_payload` (the SDK XDR
encoding of the struct) and signs them, producing a 64-byte signature.
3. **Relayer submission.** The relayer calls
`assign_task_gasless(relayer, request, signature)`. Only `relayer` authorizes
the transaction.
4. **On-chain validation** (every failure reverts): `request.contract` must be
this deployment; the ledger must not have passed `expiration_ledger`;
`request.nonce` must equal the contributor's current nonce (then incremented);
the contributor must have a registered key; `env.crypto().ed25519_verify` must
accept the signature over the re-encoded request; the task must exist and be
`Open`.

## Acceptance criteria

| Criterion | Where |
|---|---|
| Structured message validation | `GaslessAssignment` contract type + full field validation in `assign_task_gasless` |
| Verify signer credentials (Ed25519) | `env.crypto().ed25519_verify` against the contributor's registered key |
| Per-contributor nonce to prevent replay | `GaslessKey::Nonce(Address)`, checked and consumed per assignment |
| Off-chain signature parsing/validation | `gasless_assignment_payload` returns the exact bytes to sign; canonical XDR round-trip |
| Relayer execution endpoint | `assign_task_gasless(relayer, request, signature)` — relayer is sole authorizer/fee payer |
| Unit tests verifying gasless assignments | `src/gasless_test.rs` — 11 tests |

SECP256k1 is intentionally out of scope — Ed25519 is the Stellar-native key type.
It is a mechanical follow-up via `env.crypto().secp256k1_recover` behind a
key-type discriminator.

## Changes

- **`src/gasless.rs`** — new module. `GaslessAssignment` type, `GaslessKey`
storage enum, `register_signing_key` / `admin_set_signing_key` / `get_signing_key`
/ `get_assignment_nonce` / `assignment_payload` / `assign_task_gasless`. Module
header documents the full off-chain flow and JSON shape.
- **`src/lib.rs`** — registers the module; extracts the assignment state
transition from `assign_task` into a shared `apply_task_assignment` helper used
by both the interactive and gasless paths; exposes the six new contract methods.
- **`src/events.rs`** — `emit_signing_key_registered`, `emit_gasless_assignment`.
- **`src/gasless_test.rs`** — 11 unit tests (below).
- **`Cargo.toml`** — dev-dependency `ed25519-dalek = "2"` (already in the lock
graph via `soroban-sdk` testutils) to produce real signatures in tests.
- **`README.md`** — module table row.

## Test plan

- [x] `cargo test --lib gasless` — **11 passed, 0 failed**:
- `test_gasless_assignment_happy_path` — task → `InProgress`, `assignee == contributor`,
nonce 0→1, and `env.auths()` shows the relayer authorized while the contributor did not.
- `test_replay_rejected` — resubmitting the same signed message panics.
- `test_wrong_signer_rejected` — signature from an unregistered key panics.
- `test_tampered_field_rejected` — relayer swaps `contributor` after signing → panics.
- `test_expired_request_rejected` — `expiration_ledger` in the past panics.
- `test_unregistered_contributor_rejected` — no key on file panics.
- `test_wrong_contract_rejected` — request bound to another deployment panics.
- `test_non_open_task_rejected` — second gasless claim on an assigned task panics.
- `test_admin_set_signing_key_enables_full_gasless_onboarding` — admin registers key, gasless assign works.
- `test_admin_set_signing_key_rejects_non_admin` — non-admin registration panics.
- `test_nonce_advances_across_sequential_assignments` — nonce 0→1→2 over two tasks; stale nonce rejected.
- [x] `cargo test --lib` — 127 passed / 12 failed. The 12 failures are
**pre-existing on `main`** (confirmed via `git stash`): 9 `benchmark::*`,
`swap_router_test::test_refund_on_missing_oracle_price`,
`swap_router_test::test_refund_on_unresolved_route`,
`test::test_create_and_complete_task_flow`. This PR adds 11 passing tests and
introduces no regressions.
- [x] `cargo build --target wasm32-unknown-unknown --release` — clean.
- [x] `cargo clippy --lib --tests` — no new warnings in the added files.
22 changes: 22 additions & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,25 @@ pub fn emit_vesting_claimed(
(beneficiary, amount, total_claimed, env.ledger().timestamp()),
);
}

// ── Gasless Assignment Events ─────────────────────────────────────────────

pub fn emit_signing_key_registered(env: &Env, contributor: Address, set_by: Address) {
env.events().publish(
(symbol_short!("gl_key"), contributor),
(set_by, env.ledger().timestamp()),
);
}

pub fn emit_gasless_assignment(
env: &Env,
task_id: u32,
contributor: Address,
relayer: Address,
nonce: u64,
) {
env.events().publish(
(symbol_short!("gl_assign"), task_id),
(contributor, relayer, nonce, env.ledger().timestamp()),
);
}
208 changes: 208 additions & 0 deletions src/gasless.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Cryptographic signature-based *gasless* task assignment.
//
// Lets a contributor claim an open task without holding XLM or submitting a
// transaction themselves. The contributor signs a structured authorization
// message **off-chain** with their Ed25519 key; a **gas relayer** submits it
// on-chain through [`assign_task_gasless`] and pays the fees. The contract
// re-derives the exact signed bytes from the call arguments, verifies the
// signature against the contributor's registered key, and enforces a
// per-contributor **nonce** so a captured message can never be replayed.
//
// ### Off-chain flow
//
// 1. The contributor registers an Ed25519 public key once, either themselves
// via [`register_signing_key`] or through an admin via
// [`admin_set_signing_key`] (so onboarding can itself be gasless).
// 2. The contributor's client builds a [`GaslessAssignment`]:
//
// ```json
// {
// "contract": "C...", // this contract's address (domain separation)
// "task_id": 42,
// "contributor": "G...", // the address that will be assigned
// "nonce": 0, // == current on-chain nonce for `contributor`
// "expiration_ledger": 1_234_567 // request is void once the ledger passes this
// }
// ```
//
// 3. The client fetches the canonical bytes to sign from
// [`assignment_payload`] (the SDK XDR encoding of the struct) and signs
// them with the contributor's Ed25519 secret key, producing a 64-byte
// signature.
// 4. The relayer calls [`assign_task_gasless`] with `(relayer, request,
// signature)`. Only `relayer` authorizes the transaction.
//
// ### On-chain validation (all failures panic, reverting the transaction)
//
// * `request.contract` must equal this contract's address — a signature made
// for one deployment can't be replayed against another.
// * the current ledger sequence must not have passed `expiration_ledger`.
// * `request.nonce` must equal the contributor's current nonce; it is
// incremented on success, so the same signed message is single-use.
// * the contributor must have a registered signing key.
// * `env.crypto().ed25519_verify` must accept the signature over the
// re-encoded request bytes.
// * the target task must exist and be `Open` (enforced by
// [`crate::apply_task_assignment`]).

use soroban_sdk::xdr::ToXdr;
use soroban_sdk::unwrap::UnwrapOptimized;
use soroban_sdk::{contracttype, Address, Bytes, BytesN, Env};

use crate::{events, pausable, DataKey};

// ============================================================================
// Types
// ============================================================================

/// Structured, replay-protected authorization for a gasless task assignment.
/// Signed off-chain by the contributor, re-encoded and verified on-chain.
#[contracttype]
#[derive(Clone, Eq, PartialEq)]
#[cfg_attr(any(test, kani), derive(Debug))]
pub struct GaslessAssignment {
/// Address of the contract the request is bound to (domain separation).
pub contract: Address,
/// Task to assign.
pub task_id: u32,
/// Address that will be recorded as the task assignee.
pub contributor: Address,
/// Expected current nonce for `contributor`; consumed on success.
pub nonce: u64,
/// Ledger sequence after which the request is no longer valid.
pub expiration_ledger: u32,
}

#[contracttype]
pub enum GaslessKey {
/// Registered Ed25519 public key for a contributor address.
SigningKey(Address),
/// Next expected gasless-assignment nonce for a contributor address.
Nonce(Address),
}

// ============================================================================
// Signing-key registration
// ============================================================================

/// Contributor self-registers (or rotates) the Ed25519 public key that will
/// authorize their gasless task assignments.
pub fn register_signing_key(env: Env, contributor: Address, public_key: BytesN<32>) {
contributor.require_auth();
env.storage()
.instance()
.set(&GaslessKey::SigningKey(contributor.clone()), &public_key);
events::emit_signing_key_registered(&env, contributor.clone(), contributor);
}

/// Admin registers (or rotates) a contributor's Ed25519 public key on their
/// behalf, so a contributor can be onboarded without ever paying gas.
pub fn admin_set_signing_key(
env: Env,
admin: Address,
contributor: Address,
public_key: BytesN<32>,
) {
admin.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.unwrap_optimized();
if admin != stored_admin {
panic!();
}
env.storage()
.instance()
.set(&GaslessKey::SigningKey(contributor.clone()), &public_key);
events::emit_signing_key_registered(&env, contributor, admin);
}

// ============================================================================
// Views
// ============================================================================

/// The registered Ed25519 public key for `contributor`, if any.
pub fn get_signing_key(env: &Env, contributor: &Address) -> Option<BytesN<32>> {
env.storage()
.instance()
.get(&GaslessKey::SigningKey(contributor.clone()))
}

/// The next nonce a `contributor` must use when signing a gasless assignment.
/// Starts at 0 and increases by one per successful gasless assignment.
pub fn get_assignment_nonce(env: &Env, contributor: &Address) -> u64 {
env.storage()
.instance()
.get(&GaslessKey::Nonce(contributor.clone()))
.unwrap_or(0)
}

/// The canonical byte string a contributor must sign for `request`. Exposed so
/// off-chain clients sign exactly what the contract will verify.
pub fn assignment_payload(env: &Env, request: GaslessAssignment) -> Bytes {
request.to_xdr(env)
}

// ============================================================================
// Relayer endpoint
// ============================================================================

/// Relayer-submitted, signature-authorized task assignment. `relayer` is the
/// only transaction authorizer and fee payer; `request` must carry a valid
/// Ed25519 signature from `request.contributor`'s registered key.
pub fn assign_task_gasless(
env: Env,
relayer: Address,
request: GaslessAssignment,
signature: BytesN<64>,
) {
relayer.require_auth();

pausable::require_not_paused(
env.clone(),
pausable::PauseAction::AssignTask,
Some(relayer.clone()),
);

// Domain separation: the signature is bound to this exact deployment.
if request.contract != env.current_contract_address() {
panic!();
}

// Time bound.
if env.ledger().sequence() > request.expiration_ledger {
panic!();
}

// Replay protection: the signed nonce must match the live one.
let expected_nonce = get_assignment_nonce(&env, &request.contributor);
if request.nonce != expected_nonce {
panic!();
}

// Signature check against the contributor's registered key.
let public_key = match get_signing_key(&env, &request.contributor) {
Some(key) => key,
None => panic!(),
};
let message = request.clone().to_xdr(&env);
env.crypto().ed25519_verify(&public_key, &message, &signature);

// Consume the nonce before doing the assignment (effects before the
// cross-module call).
env.storage().instance().set(
&GaslessKey::Nonce(request.contributor.clone()),
&(expected_nonce + 1),
);

crate::apply_task_assignment(&env, request.task_id, request.contributor.clone());

events::emit_gasless_assignment(
&env,
request.task_id,
request.contributor,
relayer,
expected_nonce,
);
}
Loading
Loading