spec.md — go-lcpd (Lightning Compute Protocol Daemon)
This document is the implementation specification (single source of truth) forgo-lcpd/ in this repository.
It is written in WYSIWID format (Concepts + Synchronizations).
Sources of truth:
- gRPC API:
go-lcpd/proto/lcpd/v1/lcpd.proto - LCP wire protocol:
protocol/protocol.md(LCP v0.2,protocol_version=2)
lnd BOLT #1 custom messages as the transport.
It exposes a gRPC API (lcpd.v1.LCPDService) for local clients to drive the requester-side LCP flow (quote → pay → stream(result) → complete).
The same process can also run as a provider-side handler (when configured) to process inbound lcp_quote_request, receive the input stream, return an invoice, and then stream the result and send lcp_result.
It does not implement its own P2P (TCP) transport.
Protobuf is managed via buf. The goal is to keep the following integration tests reproducible within this project:
- gRPC: smoke check
GetLocalInfo/ListLCPPeers(no external processes) - Lightning (custom messages, regtest): two nodes exchange
lcp_manifestandListLCPPeerscan enumerate LCP-capable peers (opt-in) - Lightning (LCP provider, regtest): complete inbound
lcp_quote_request→ input stream → invoice → result stream →lcp_resultover custom messages (opt-in) - Lightning (LCP requester, regtest): receive result stream +
lcp_resultviaRequestQuote→AcceptAndExecute(opt-in)
Security & Architectural Constraints
These constraints are system invariants.- Do not modify BOLTs: Do not propose or require changes to Lightning BOLT specs (L2). Rationale: preserve interoperability.
-
Do not implement a custom P2P (TCP) transport: go-lcpd MUST NOT implement peer transport over TCP/UDP. Delegate peer messaging to
lndpeer connections. Rationale: avoid re-implementation and reduce operational risk. -
LCP message types MUST match
protocol/protocol.md: Type numbers forlcp_manifest, etc. MUST follow the odd assignments inprotocol/protocol.md. Rationale: coexistence with non-LCP peers. -
BOLT #1 unknown message rule: Ignore unknown odd messages. Disconnect on unknown even messages via
lnrpc.Lightning.DisconnectPeer. Rationale: match BOLT #1 parity behavior. -
Enforce
protocol_version=2: LCP v0.2 usesprotocol_version=2(encoding:major*100 + minor). Implementations MUST send and accept only version 2. Rationale: interoperability. -
Manifest-gated job messages: go-lcpd MUST NOT send any job-scope messages until it has (1) sent
lcp_manifestand (2) received the peer’slcp_manifest. Rationale: required byprotocol/protocol.mdand needed to learn peer limits (max_payload_bytes, etc.). -
Preserve TLV stream canonical form: Encode TLVs as
bigsize(type)→bigsize(length)→value. Sort by type. Do not duplicate types. Rationale: byte-exactterms_hashand forward compatibility. - Do not disconnect on unknown TLVs: Unknown TLVs MUST NOT cause disconnection. Continue decoding. Rationale: forward compatibility.
-
openai_chat_completions_v1_params_tlvsis strict: Providers MUST reject unknown param types withlcp_error(code=unsupported_params). Rationale: fixed interpretation of typed params. -
Always include job-scope envelope TLVs: All LCP messages except
lcp_manifestMUST includejob_id,msg_id, andexpiryperprotocol/protocol.md. Rationale: replay safety and idempotency. -
Deterministic chunk
msg_id: Forlcp_stream_chunk,msg_idMUST beSHA256(stream_id || u32be(seq))perprotocol/protocol.md. Rationale: bounded replay state for large streams. -
Ignore expired messages (Provider inbound): Providers MUST ignore inbound job-scope messages where
expiry < now. Rationale: replay resistance. -
Clamp envelope expiry window (Provider inbound): Providers MUST use
effective_expiry = min(expiry, now + MAX_ENVELOPE_EXPIRY_WINDOW_SECONDS)perprotocol/protocol.md. Rationale: prevent state-holding DoS via far-futureexpiry. -
Ignore duplicate messages (Provider inbound): Providers MUST de-duplicate inbound job-scope messages by (
peer_pubkey,job_id,msg_id) untileffective_expiry. Rationale: idempotency.- For
lcp_stream_chunk, go-lcpd MUST treatseq < expected_seqas a duplicate and MUST ignore it (without storing per-chunk replay entries).
- For
-
LCP v0.2
terms_hash: Computeterms_hashbyte-exactly as defined inprotocol/protocol.md(including canonicalization ofparamsand input stream metadata). Rationale: invoice binding depends on byte-exactness. -
Provider execution rule: Providers MUST NOT execute or deliver results for
job_idbefore invoice settlement. Rationale:protocol/protocol.mdexecution rule. -
Requester invoice checks: Before paying, verify
description_hash == terms_hash,payee_pubkey == peer_id,invoice_amount_msat == price_msat, andinvoice_expiry_unix <= quote_expiry(withALLOWED_CLOCK_SKEW_SECONDS). Rationale: invoice swapping defense and amount/expiry integrity. -
Do not pay past quote expiry: Reject Terms where
quote_expiry <= now. Rationale: avoid unnecessary payments. -
Default constants: Use these defaults unless configured:
MAX_ENVELOPE_EXPIRY_WINDOW_SECONDS = 600ALLOWED_CLOCK_SKEW_SECONDS = 5DEFAULT_MAX_STORE_ENTRIES = 1024(forReplayStore,ProviderJobStore, andRequesterJobStore)DEFAULT_MAX_PAYLOAD_BYTES = 16384(forlcp_manifest.max_payload_bytes)DEFAULT_MAX_STREAM_BYTES = 4_194_304(forlcp_manifest.max_stream_bytes, 4 MiB)DEFAULT_MAX_JOB_BYTES = 8_388_608(forlcp_manifest.max_job_bytes, 8 MiB)
-
Defaults override env vars:
LCP_MAX_ENVELOPE_EXPIRY_WINDOW_SECONDSLCP_ALLOWED_CLOCK_SKEW_SECONDSLCP_DEFAULT_MAX_STORE_ENTRIESLCP_INVOICE_EXPIRY_SLACK_SECONDS(optional; defaults toLCP_ALLOWED_CLOCK_SKEW_SECONDS)
-
Payload/stream limit checks: Do not send any LCP message whose payload exceeds
remote_manifest.max_payload_bytes. In particular, input/result streaming MUST chunk intolcp_stream_chunkmessages that fit under the peer limit. gRPC returnsRESOURCE_EXHAUSTED. Provider returnslcp_error(code=payload_too_large). Rationale: DoS resistance and compatibility. -
Enforce declared stream limits: go-lcpd MUST enforce
max_stream_bytesandmax_job_bytesfrom the local manifest for inbound streams. Rationale: protocol-defined DoS bounds. - Prefer regtest for Lightning integration: Development/integration tests MUST use regtest. They MUST NOT depend on mainnet/testnet. Rationale: safety and reproducibility.
-
Do not implicitly require external processes:
go test ./...MUST NOT requirebitcoindorlnd. Lightning integration tests MUST be opt-in. Rationale: developer experience and safety. - Integration tests MUST NOT depend on external networks: CI/dev MUST not require external APIs. Keep smoke tests opt-in. Rationale: reproducibility.
-
Do not log secrets: Logs MUST NOT contain API keys, access tokens, macaroons, BOLT11 invoices (
payment_request), raw prompts, or raw model outputs at any log level. Rationale: logs leak. - Primary interface is gRPC: go-lcpd MUST be operable via gRPC. Do not make CLI the primary interface. Rationale: stabilize an API shape for integrations.
-
Do not provide
cmd/: go-lcpd MUST NOT provide CLI entry points undergo-lcpd/cmd/*. Rationale: keep operations centered on gRPC. -
Manage protobuf with buf: API protobufs MUST be generated via
buf. Rationale: change detection, compatibility, reproducible generation. -
DI and lifecycle: Go components MUST be wired with
go.uber.org/fx. Rationale: explicit composition and testability. -
Do not assume Nix: Core workflows SHOULD work with a standard Go toolchain and externally installed binaries.
flake.nixMAY exist as an optional pinned devShell. Rationale: onboarding.
Concepts
ProtocolCompatibility
Purpose: Provide LCP-compatible core primitives that remain stable across protocol versions. Domain Model:ProtocolVersion: uint16 (const 2)JobID: 32 bytesTermsCore:protocol_version,job_id,price_msat,quote_expiry_unix_seconds,task_kind,input_hash32,input_len,input_content_type,input_content_encoding,params_hash32TermsHash: 32 bytes (SHA256) Actions:new_job_id() -> job_idcompute_params_hash(params_bytes_opt) -> params_hash32 | errorcompute_terms_hash(terms_core) -> terms_hashOperational Principle:terms_hashMUST be computed exactly asprotocol/protocol.mddefines (canonical TLV encoding + SHA256).terms_hashMUST be computed only from the canonicalterms_tlvsfields inprotocol/protocol.md(do not includepayment_request, etc.).compute_params_hashMUST followprotocol/protocol.md’scanonical_tlv(params)rules:- If
paramsis absent:params_hash = SHA256(empty). - If
paramsis present: decode TLVs (reject invalid), re-encode canonically (ascending type, no duplicates), thenSHA256(canonical_bytes).
- If
- The gRPC API represents
quote_expiryasgoogle.protobuf.Timestamp.terms_hashuses onlyquote_expiry.seconds(Unix epoch seconds). Nanos are always 0.
LCPTasks
Purpose: Provide strict task definitions fromgo-lcpd/proto/lcpd/v1/lcpd.proto and pin down the mapping to the wire format.
Domain Model:
LCPTaskKind:LCP_TASK_KIND_UNSPECIFIED|LCP_TASK_KIND_OPENAI_CHAT_COMPLETIONS_V1Task:oneof spec(currently onlyOpenAIChatCompletionsV1TaskSpec)OpenAIChatCompletionsV1TaskSpec: request_json([]byte), params(OpenAIChatCompletionsV1Params)OpenAIChatCompletionsV1Params: model(string) Actions:validate_task(task) -> ok | errorto_wire_quote_request_task(task) -> task_kind(string), params_bytes(*[]byte) | errorto_wire_input_stream(task) -> decoded_bytes([]byte), content_type(string), content_encoding(string) | errorOperational Principle:- This implementation supports only
openai.chat_completions.v1and MUST reject everything else. OpenAIChatCompletionsV1TaskSpec.request_jsonMUST be non-empty valid JSON.OpenAIChatCompletionsV1Params.modelMUST NOT be empty.request_json.modelMUST be present and MUST matchparams.model.request_json.messagesMUST be present and MUST be non-empty.request_json.streamMUST be omitted or false (streaming is not supported yet).- Wire mapping (
protocol/protocol.md):task_kind = "openai.chat_completions.v1"- input stream decoded bytes = raw
request_jsonbytes (UTF-8 JSON) - input stream metadata:
content_type = "application/json; charset=utf-8",content_encoding = "identity" params = openai_chat_completions_v1_params_tlvs(must include at leastmodel)
LCPWire
Purpose: Encode/decode the LCP wire messages (TLV streams) defined inprotocol/protocol.md.
Domain Model:
MessageType:lcp_manifest(42081)/lcp_quote_request(42083)/lcp_quote_response(42085)/lcp_result(42087)/lcp_stream_begin(42089)/lcp_stream_chunk(42091)/lcp_stream_end(42093)/lcp_cancel(42095)/lcp_error(42097)JobEnvelope: protocol_version(uint16), job_id(32 bytes), msg_id(32 bytes), expiry(uint64)Manifest: protocol_version, max_payload_bytes(uint32), max_stream_bytes(uint64), max_job_bytes(uint64), max_inflight_jobs(*uint16), supported_tasks([]TaskTemplate)TaskTemplate: task_kind(string), params_bytes(*[]byte)QuoteRequest: envelope, task_kind(string), params_bytes(*[]byte)QuoteResponse: envelope, price_msat(uint64), quote_expiry(uint64), terms_hash(32 bytes), payment_request(string)StreamKind:input(1)|result(2)StreamBegin: envelope, stream_id(32 bytes), stream_kind(uint16), total_len(*uint64), sha256(*32 bytes), content_type(string), content_encoding(string)StreamChunk: envelope, stream_id(32 bytes), seq(uint32), data([]byte)StreamEnd: envelope, stream_id(32 bytes), total_len(uint64), sha256(32 bytes)Result: envelope, status(uint16), ok(*ResultOK), message(*string)ResultOK: result_stream_id(32 bytes), result_hash(32 bytes), result_len(uint64), result_content_type(string), result_content_encoding(string)Cancel: envelope, reason(*string)Error: envelope, code(uint16), message(*string)OpenAIChatCompletionsV1Params: model(string), unknown(map[uint64][]byte) Actions:encode_manifest(m) -> payload_bytes | errordecode_manifest(payload_bytes) -> manifest | errorencode_quote_request(q) -> payload_bytes | errordecode_quote_request(payload_bytes) -> quote_request | errorencode_quote_response(q) -> payload_bytes | errordecode_quote_response(payload_bytes) -> quote_response | errorencode_stream_begin(b) -> payload_bytes | errordecode_stream_begin(payload_bytes) -> stream_begin | errorencode_stream_chunk(c) -> payload_bytes, msg_id | errordecode_stream_chunk(payload_bytes) -> stream_chunk | errorencode_stream_end(e) -> payload_bytes | errordecode_stream_end(payload_bytes) -> stream_end | errorencode_result(r) -> payload_bytes | error(encodeslcp_result)decode_result(payload_bytes) -> result | error(decodeslcp_result)encode_cancel(c) -> payload_bytes | errordecode_cancel(payload_bytes) -> cancel | errorencode_error(e) -> payload_bytes | errordecode_error(payload_bytes) -> lcp_error | errorencode_openai_chat_completions_v1_params(p) -> params_bytes | errordecode_openai_chat_completions_v1_params(params_bytes) -> params | errorOperational Principle:- Treat
lcp_manifestas connection-scoped (do not include job envelope TLVs). - Job-scope messages (everything except
lcp_manifest) MUST includeJobEnvelope(protocol_version,job_id,msg_id,expiry). lcp_stream_chunk.msg_idMUST be deterministic (SHA256(stream_id || u32be(seq))).encode_stream_chunkMUST compute it;decode_stream_chunkMUST verify it.openai_chat_completions_v1_params_tlvsMUST preserve unknown TLVs inunknownand MUST NOT fail decoding solely due to unknown TLVs (forward compatibility).
LCPMessageRouter
Purpose: Classify inbound custom messages into known LCP message types and apply the BOLT #1 parity rule for unknown message types. Domain Model:CustomMessage: peer_pubkey, msg_type(uint16), payload_bytesRouteDecision: action(dispatch_manifest|dispatch_quote_request|dispatch_quote_response|dispatch_stream_begin|dispatch_stream_chunk|dispatch_stream_end|dispatch_result|dispatch_cancel|dispatch_error|ignore|disconnect), reason Actions:route(custom_message) -> decisionOperational Principle:- LCP v0.2 message types follow the assignments in
protocol/protocol.md(all odd). - Unknown odd is ignored; unknown even triggers disconnect (BOLT #1 parity rule).
routedoes not decode the payload (classification only).
ReplayStore
Purpose: Provide idempotency (de-duplication) and replay safety for job-scope messages. Domain Model:ReplayKey: peer_pubkey, job_id(32 bytes), msg_id(32 bytes)ReplayEntry: key, expiry(uint64) Actions:check_and_remember(key, effective_expiry, now) -> ok | duplicate | expiredprune(now) -> voidOperational Principle:- Messages with
expiry < nowMUST be treated asexpiredand ignored (protocol/protocol.md). duplicateentries MUST be retained untileffective_expiry(de-dup at least untileffective_expiry).- The store MUST be pruned by TTL and MUST have bounds (e.g., max entries) to cap memory usage.
- Default bounds MUST be
DEFAULT_MAX_STORE_ENTRIES = 1024.
ProviderJobStore
Purpose: Hold Provider-side job state (input stream / quote / payment / execute / result streaming) to support idempotency and cancellation. Domain Model:JobKey: peer_pubkey, job_idJobState: awaiting_input | waiting_payment | paid | executing | streaming_result | done | canceled | failedInputStreamState: stream_id(32 bytes), content_type(string), content_encoding(string), expected_seq(uint32), buf([]byte), total_len(uint64), sha256(32 bytes), validated(bool)Job: peer_pubkey, job_id, state, quote_request, input_stream(*InputStreamState), quote_expiry, params_hash32, input_hash32, input_len, input_content_type, input_content_encoding, terms_hash, payment_hash, invoice_add_index, quote_response, created_at Actions:get(job_key) -> job | not_foundupsert(job) -> voidupdate_state(job_key, state) -> voidset_quote_response(job_key, quote_response) -> voidset_payment_hash(job_key, payment_hash) -> voidOperational Principle:- Since
job_idcan collide across peers, jobs MUST be keyed bypeer_pubkeyas well. - The store MUST have bounds (e.g., max entries) and MUST NOT retain pending state past
quote_expiry(evict/GC). Rationale: prevent state-holding DoS via unbounded job creation. - For jobs in
awaiting_input, implementations SHOULD evict/GC state onceexpiry(from the job envelope) is past the retention window (effective_expiry). Rationale: prevent state-holding DoS via incomplete streams. - Default bounds MUST be
DEFAULT_MAX_STORE_ENTRIES = 1024.
ComputeBackend
Purpose: Executeopenai.chat_completions.v1 on the Provider side and produce output bytes.
Domain Model:
Task: task_kind(string), model(string), input_bytes([]byte), params_bytes([]byte)ExecutionResult: output_bytes([]byte), usage Actions:execute(ctx, task) -> execution_result | errorOperational Principle:- In integration tests,
executeMUST be a deterministic stub/dummy implementation (connectivity and reproducibility come first). - For
task_kind="openai.chat_completions.v1",input_bytesis treated as raw UTF-8 JSON request bytes forPOST /v1/chat/completions. - For
task_kind="openai.chat_completions.v1",task.modelis the upstream model ID (fromrequest_json.model/params.model). - For
task_kind="openai.chat_completions.v1",params_bytesMUST be empty (request configuration is carried ininput_bytes). output_bytesMUST be returned as raw OpenAI-compatible non-streaming response body bytes (JSON).
LLMExecutionPolicy
Purpose: Controlopenai.chat_completions.v1 max output tokens for pricing, DoS resistance, and determinism.
Domain Model:
ExecutionPolicy: max_output_tokens(uint32)ExecutionPolicyProvider:policy() -> ExecutionPolicyActions:policy() -> ExecutionPolicyOperational Principle:policy.max_output_tokensMUST be > 0.- If the request specifies an output-token cap (
max_completion_tokens/max_tokens/max_output_tokens), the Provider MUST reject values that exceedpolicy.max_output_tokens. - If the request does not specify an output-token cap, the Provider MUST use
policy.max_output_tokensfor quote-time estimation.
LLMUsageEstimator
Purpose: Estimateopenai.chat_completions.v1 token usage during quoting to drive pricing decisions.
Domain Model:
UsageEstimate: input_tokens(uint64), max_output_tokens(uint64), total_tokens(uint64)Estimation: usage, resources([]ResourceEstimate), estimator_id(string)ResourceEstimate: name(string), amount(uint64) Actions:estimate(task, policy) -> estimation | errorOperational Principle:- Token estimation uses
approx.v1:input_tokens = ceil(len(input_bytes)/4),max_output_tokens = policy.max_output_tokens. task_kind!="openai.chat_completions.v1"MUST be rejected.
LLMPricing
Purpose: Computeprice_msat from estimated token usage on the Provider side.
Domain Model:
PriceTableEntry: model(string), input_msat_per_mtok(uint64), cached_input_msat_per_mtok(*uint64), output_msat_per_mtok(uint64)PriceTable: map[model]PriceTableEntryPriceBreakdown: input_tokens, cached_input_tokens, output_tokens, price_msat(uint64) Actions:quote_price(model, usage_estimate, cached_input_tokens, price_table) -> price_breakdown | errorOperational Principle:- Prices are computed from a table in units of “msat per 1M tokens”, rounding up (ceil).
- Unknown models MUST be rejected.
- Overflows MAY be treated as equivalent to
rate_limited.
LCPProvider
Purpose: Act as an LCP v0.2 Provider: process job-scope messages and complete quote → payment → stream(result) →lcp_result.
Domain Model:
ProviderConfig: enabled(bool), quote_ttl_seconds(uint64), pricing(InFlightSurge), models(map[model]ModelConfig)ProviderRuntime: execution_policy(ExecutionPolicyProvider), usage_estimator(LLMUsageEstimator)InFlightSurge: threshold(uint32), per_job_bps(uint32), max_multiplier_bps(uint32)ModelConfig: max_output_tokens(*uint32), price(PriceTableEntry) Actions:handle_quote_request(peer_pubkey, quote_request, now) -> voidhandle_stream_begin(peer_pubkey, stream_begin, now) -> voidhandle_stream_chunk(peer_pubkey, stream_chunk, now) -> voidhandle_stream_end(peer_pubkey, stream_end, now) -> voidhandle_cancel(peer_pubkey, cancel, now) -> voidOperational Principle:- Provider mode MUST be explicitly enabled (
lcpd-grpcdrequiresenabled: truein the YAML atLCPD_PROVIDER_CONFIG_PATH). - If Provider is disabled (or the compute backend is disabled), inbound
lcp_quote_requestMUST be rejected aslcp_error(code=unsupported_task)(do not create invoices or execute). - The Provider MUST decode job-scope messages and validate
protocol_versionand the task (unsupported cases returnlcp_error). openai_chat_completions_v1_params_tlvsare strict: if unknown TLV types are present, returnlcp_error(code=unsupported_params).openai.chat_completions.v1inputs MUST be valid JSON and MUST satisfy:request_json.modelis required.request_json.messagesis required and must be non-empty.request_json.streammust be omitted or false.params.modelis required and must matchrequest_json.model.
- If
modelsis non-empty, requests whoseparams.modelis not in the set MUST be rejected aslcp_error(code=unsupported_task). - If
modelsis empty, anyparams.modelMAY be accepted, but the manifest MUST NOT advertisesupported_tasks. - The Provider MUST de-duplicate inbound job-scope messages via
ReplayStore, and drop messages whereexpiry < now.- For
lcp_stream_chunk, de-duplication is performed by stream state (seq < expected_seqis ignored as a duplicate).
- For
- For multiple
lcp_quote_requestmessages with the samejob_id, the Provider SHOULD return the samelcp_quote_responsewithin TTL once it has been issued (idempotency). - If an existing job’s
terms_hashdoes not match the recomputed value, the Provider SHOULD returnlcp_error(code=payment_invalid). - Input stream gating (v0.2):
- After accepting
lcp_quote_request, the Provider MUST require exactly one validated input stream (stream_kind=input) before issuing a quote. - The Provider MUST NOT send
lcp_quote_responseuntil it has validated the input stream and derived (input_hash,input_len,input_content_type,input_content_encoding) as required byprotocol/protocol.md.
- After accepting
- Stream receive rules (v0.2):
- For input streams,
lcp_stream_beginMUST includetotal_lenandsha256. content_encoding="identity"MUST be supported; unknown encodings MUST be rejected withlcp_error(code=unsupported_encoding).seqMUST start at 0 and increase by exactly 1;seq < expected_seqis ignored as a duplicate;seq > expected_seqMUST returnlcp_error(code=chunk_out_of_order).- The Provider MUST enforce local limits (
max_payload_bytes,max_stream_bytes,max_job_bytes) for inbound streams. - Stream end validation failure MUST return
lcp_error(code=checksum_mismatch).
- For input streams,
price_msatis computed after input stream validation via max-output-token planning +LLMUsageEstimator.estimate+LLMPricing.quote_price(fixed at quote-time; do not recompute on settlement).- The Provider MUST compute
terms_hashand MUST set the BOLT11 invoicedescription_hashto exactly equalterms_hash(invoice swapping defense). - The Provider MUST NOT start execution before invoice settlement.
- When executing, the Provider MUST pass
params.modelto the compute backend astask.model. - Result delivery (v0.2):
- For
task_kind="openai.chat_completions.v1", output bytes MUST be treated as raw OpenAI-compatible response body bytes (JSON). - The Provider SHOULD set
result_content_type = "application/json; charset=utf-8"and MUST useresult_content_encoding = "identity". - The Provider MUST send exactly one result stream (
stream_kind=result) after invoice settlement and beforelcp_result(status=ok). - The Provider MUST include in
lcp_result(status=ok)the metadata matching the validated result stream (result_stream_id,result_hash,result_len,result_content_type,result_content_encoding).
- For
- Outbound sizing:
- When sending any message, the Provider MUST respect the peer’s declared receive limits in
remote_manifest(max_payload_bytes,max_stream_bytes,max_job_bytes). - Result streaming MUST be chunked so each message payload fits within
remote_manifest.max_payload_bytes.
- When sending any message, the Provider MUST respect the peer’s declared receive limits in
- Failure handling:
- If compute fails after payment, the Provider SHOULD send
lcp_result(status=failed, message=...). - On receiving
lcp_cancel, the Provider SHOULD stop work best-effort and SHOULD sendlcp_result(status=cancelled).
- If compute fails after payment, the Provider SHOULD send
RequesterJobStore
Purpose: Hold job state between gRPCRequestQuote and AcceptAndExecute.
Domain Model:
Job: peer_id, job_id, task, terms, state, created_at, updated_atJobState: quoted | paying | awaiting_result | done | cancelled | failed | expired Actions:put_quote(peer_id, task, terms) -> voidget_terms(peer_id, job_id) -> terms | errormark_state(peer_id, job_id, state) -> voidgc() -> voidOperational Principle:- Jobs past
quote_expiryMUST be treated asexpired. - The store MUST have bounds (e.g., max entries) and MUST evict/GC expired jobs. Rationale: prevent local state growth under repeated
RequestQuote. JobStoreMAY be an in-process memory implementation.- Default bounds MUST be
DEFAULT_MAX_STORE_ENTRIES = 1024.
RequesterWaiter
Purpose: Deliver inboundlcp_quote_response / result stream (lcp_stream_*) / lcp_result / lcp_error to the corresponding waiting gRPC calls.
Domain Model:
Key: peer_id, job_idQuoteOutcome: quote_response(*QuoteResponse) | error(*Error)ResultStreamState: stream_id(32 bytes), expected_seq(uint32), buf([]byte), content_type(string), content_encoding(string), validated(bool), total_len(uint64), sha256(32 bytes)ResultOutcome: ok(*RequesterResult) | error(*Error)RequesterResult: output_bytes([]byte), content_type(string) Actions:wait_quote_response(ctx, peer_id, job_id) -> QuoteOutcome | errorwait_result(ctx, peer_id, job_id) -> ResultOutcome | errorhandle_inbound_custom_message(peer_pubkey, msg_type, payload_bytes) -> voidOperational Principle:- Inbound payloads are decoded via
LCPWire.decode_quote_response/decode_stream_begin/decode_stream_chunk/decode_stream_end/decode_result/decode_error. - The waiter MUST reconstruct exactly one validated result stream (
stream_kind=result) per job before completing a successfulwait_result. - On
lcp_result(status=ok), the waiter MUST verify thelcp_resultmetadata matches the validated result stream (stream_id,sha256,total_len,content_type,content_encoding). - For the same (peer_id, job_id), allow at most one concurrent waiter for quote/result; additional waiters must fail.
lcp_erroris delivered to both the quote and result waiters.- The requester-side waiter does not implement expiry/replay filtering.
PeerDirectory
Purpose: Track connected peers and their LCP readiness (vialcp_manifest), and provide list_lcp_peers().
Domain Model:
PeerID: string (Lightning pubkey hex,lnrpc.Peer.pub_key)Peer: peer_id, address, connected, custom_msg_enabled, manifest_sent, remote_manifest, lcp_ready Actions:upsert_peer(peer_id, address) -> voidmark_connected(peer_id) -> voidmark_disconnected(peer_id) -> voidmark_custom_msg_enabled(peer_id, enabled) -> voidmark_manifest_sent(peer_id) -> voidmark_lcp_ready(peer_id, remote_manifest) -> void(recordsremote_manifest)list_lcp_peers() -> []Peerremote_manifest_for(peer_id) -> manifest | not_foundOperational Principle:lcp_readymeans: connected +custom_msg_enabled=true+manifest_sent=true+remote_manifestpresent.list_lcp_peers()returns only peers that arelcp_ready.
LNDPeerMessaging
Purpose: Send/receive BOLT #1 custom messages over lnd peer connections, and observe peer online/offline and inbound messages. Domain Model:LNDConnection: rpc_addr, tls_cert_path, admin_macaroon_pathInboundCustomMessage: peer_pubkey, msg_type(uint16), payload_bytes Actions:dial(conn) -> grpc_conn | errorlist_peers(grpc_conn) -> []PeerSnapshot | errorsubscribe_peer_events(grpc_conn) -> stream<PeerEvent> | errorsubscribe_custom_messages(grpc_conn) -> stream<CustomMessage> | errorsend_custom_message(grpc_conn, peer_pubkey, msg_type, payload_bytes) -> ok | errordisconnect_peer(grpc_conn, peer_pubkey) -> ok | errorOperational Principle:- If
LCPD_LND_MANIFEST_RESEND_INTERVALis unset or<= 0,lcp_manifestMUST be sent at most once per connection (to avoid infinite loops). - If
LCPD_LND_MANIFEST_RESEND_INTERVALis set to a positive duration, go-lcpd SHOULD periodically re-sendlcp_manifestto connected peers on that interval. - If
lcp_manifestis received and we have not sent ours yet, reply once (SHOULD). If we have already sent ours, do not reply. - Inbound messages are classified via
LCPMessageRouter; manifests are decoded internally and applied toPeerDirectory.
LightningRPC
Purpose: Provide minimal invoice operations via the lnd gRPC API (create/verify/pay). Domain Model:PaymentRequest: string (BOLT11)PayeePubKey: string (invoice destination pubkey hex;DecodePayReq.destination)DescriptionHash: 32 bytesPaymentHash: 32 bytes (AddInvoiceResponse.r_hash)InvoiceAddIndex: uint64 (AddInvoiceResponse.add_index)PaymentPreimage: 32 bytes Actions:get_info() -> info | errorcreate_invoice(description_hash, price_msat, expiry_seconds) -> payment_request, payment_hash, add_index | errorwait_for_settlement(payment_hash, add_index) -> ok | errordecode_payment_request(payment_request) -> description_hash, payee_pubkey | errorpay_invoice(payment_request) -> payment_preimage | errorOperational Principle:- Providers MUST create invoices with
description_hash=terms_hash. - Before paying, the Requester MUST verify
description_hash==terms_hashandpayee_pubkey==peer_id.
GRPCService
Purpose: Provide the gRPC API for operating go-lcpd. Domain Model:Proto:go-lcpd/proto/lcpd/v1/lcpd.proto(managed by buf)Service:lcpd.v1.LCPDServiceRPC:ListLCPPeers/GetLocalInfo/RequestQuote/AcceptAndExecute/CancelJobActions:ListLCPPeers(request) -> responseGetLocalInfo(request) -> responseRequestQuote(request) -> responseAcceptAndExecute(request) -> responseCancelJob(request) -> responseOperational Principle:- The protobuf schema is the API contract; do not expose Go internal domain types directly.
- Invalid input returns a gRPC status (e.g.,
InvalidArgument). AcceptAndExecuteis a blocking call; clients SHOULD set a deadline.
RegtestDevnet
Purpose: Provide a repo-local regtest devnet (Bitcoin Core + lnd) to reproduce Lightning integration locally (invoice binding / payments). Domain Model:Devnet: data_dir, bitcoind_rpc_addr, lnd_nodesLNDNode: node_name, rpc_addr, tls_cert_path, admin_macaroon_path Actions:up() -> ok | errordown() -> ok | errorpaths(node_name) -> LNDNodelncli(node_name, args...) -> stdout | errorbitcoin_cli(args...) -> stdout | errorOperational Principle:- The devnet is started via
./scripts/devnetand stores all state under./.data/devnet/. - RPC must bind to localhost only.
Synchronizations
sync job_state_machine_requester
Summary: Define an intuitive state machine for a Requester job (via gRPC). Diagrams:sync job_state_machine_provider
Summary: Define an intuitive state machine for a Provider job. Diagrams:sync lnd_peer_messaging_startup
Summary: On startup, connect to lnd, load an initial peer snapshot, and start subscription loops. When: on go-lcpd startup. Then:- Connect to lnd via
LNDPeerMessaging.dial(if not configured, disable integration and do nothing). - Fetch currently connected peers via
LNDPeerMessaging.list_peersand apply them toPeerDirectory(connected + custom_msg_enabled). - For each peer, send
lcp_manifestonce if it has not been sent yet and callPeerDirectory.mark_manifest_sent. - If
LCPD_LND_MANIFEST_RESEND_INTERVALis set to a positive duration, start a periodic loop that re-sendslcp_manifestto connected peers on that interval. - Start the
subscribe_peer_eventsandsubscribe_custom_messagesloops.
sync lnd_inbound_custom_message_dispatch
Summary: Classify inbound custom messages; apply manifests toPeerDirectory; dispatch the rest to handlers.
When: LNDPeerMessaging receives a CustomMessage.
Then:
- Classify via
LCPMessageRouter.route. - For
dispatch_manifest, decode viaLCPWire.decode_manifest, callPeerDirectory.mark_lcp_ready(recordingremote_manifest), and if we have not sent ours yet, reply withlcp_manifestonce and callPeerDirectory.mark_manifest_sent. - For
dispatch_quote_request/dispatch_cancel, dispatch to the Provider handler (replay/expiry handling lives on the Provider handler side). - For
dispatch_quote_response/dispatch_result/dispatch_error, dispatch to the requester-side waiter (bound to the waiting gRPC call). - For
dispatch_stream_begin/dispatch_stream_chunk/dispatch_stream_end, dispatch to both the Provider handler and the requester-side waiter. Each side MUST ignore streams that do not match its expected state (input vs result). - For
disconnect, callDisconnectPeer. - For
ignore, do nothing.
sync grpc_get_local_info
Summary: Return localnode_id and manifest via GetLocalInfo.
When: GRPCService.GetLocalInfo is called.
Then:
- Call
LightningRPC.get_infoand useidentity_pubkeyasnode_id. - Build
manifestfrom local config (protocol_version=2,max_payload_bytes=DEFAULT_MAX_PAYLOAD_BYTES,max_stream_bytes=DEFAULT_MAX_STREAM_BYTES,max_job_bytes=DEFAULT_MAX_JOB_BYTES). Includesupported_tasksonly when Provider is enabled andmodelsis non-empty. - Return
GetLocalInfoResponse{node_id, manifest}.
sync grpc_list_lcp_peers
Summary: Enumerate connected LCP-capable peers (those that have responded withlcp_manifest) via ListLCPPeers.
When: GRPCService.ListLCPPeers is called.
Then:
- Call
PeerDirectory.list_lcp_peers(). - Pack each peer’s
peer_id/address/remote_manifestinto the gRPC response and return it.
sync grpc_request_quote
Summary: Sendlcp_quote_request via RequestQuote and return lcp_quote_response as Terms.
When: GRPCService.RequestQuote is called.
Where:
- Gather and validate inputs (
peer_id,task). - Precondition: the target peer is
lcp_readyinPeerDirectory(otherwise return FAILED_PRECONDITION). Then: - Call
LCPTasks.validate_task. - Generate
job_id, amsg_idforlcp_quote_request, andexpiry(expiry = now + 300 seconds). - Build a
QuoteRequestby callingLCPTasks.to_wire_quote_request_taskto gettask_kind/params_bytes. - Encode
lcp_quote_requestviaLCPWire.encode_quote_request. If it would exceed the peer’sremote_manifest.max_payload_bytes, return RESOURCE_EXHAUSTED (do not send). - Send
lcp_quote_requestviaSendCustomMessage(type=42083). - Build the input stream via
LCPTasks.to_wire_input_streamto getdecoded_bytes/content_type/content_encoding. - Verify the input fits the peer’s declared stream limits (
len(decoded_bytes) <= remote_manifest.max_stream_bytesand<= remote_manifest.max_job_bytes), otherwise return RESOURCE_EXHAUSTED (do not send). - Send the input stream:
- Generate
stream_idand amsg_idforlcp_stream_begin. - Send
lcp_stream_begin(stream_kind=input, total_len=len(decoded_bytes), sha256=SHA256(decoded_bytes), content_type, content_encoding). - Send
lcp_stream_chunk× N withseq=0..N-1, choosing chunk sizes so each encoded message payload fits withinremote_manifest.max_payload_bytes. - Send
lcp_stream_end(total_len, sha256)(with a fresh randommsg_id).
- Generate
- Wait until deadline for
lcp_quote_responseorlcp_errorwith the samejob_id. - On
lcp_quote_response, recompute and verifyterms_hash(including input stream metadata and canonicalizedparams_hash), callRequesterJobStore.put_quote, and returnRequestQuoteResponse{terms}.
sync grpc_accept_and_execute
Summary: Pay the invoice inAcceptAndExecute, wait for the result stream + lcp_result, and return Result.
When: GRPCService.AcceptAndExecute is called.
Where:
- Gather and validate inputs (
peer_id,job_id,pay_invoice). - Precondition:
RequesterJobStorehas a quote forjob_id(otherwise NOT_FOUND). Then: - Verify
pay_invoice=true(false is INVALID_ARGUMENT). - Verify
quote_expiryhas not passed (if it has, FAILED_PRECONDITION). - Call
LightningRPC.decode_payment_request(terms.payment_request)and verify invoice binding (failure is FAILED_PRECONDITION):description_hash == terms.terms_hashpayee_pubkey == peer_idinvoice_amount_msat == price_msatinvoice_expiry_unix <= quote_expiry (+ ALLOWED_CLOCK_SKEW_SECONDS)
- Call
LightningRPC.pay_invoice, then wait until deadline for either:- a validated result stream + matching
lcp_result(status=ok), or lcp_error.
- a validated result stream + matching
- On success, return
AcceptAndExecuteResponse{result}whereresult.resultis the reconstructed result bytes andresult.content_typematches the result stream metadata.
sync grpc_cancel_job
Summary: Sendlcp_cancel via CancelJob (best-effort).
When: GRPCService.CancelJob is called.
Then:
- Generate
msg_idandexpiry(expiry = now + 300 seconds). - Build payload via
LCPWire.encode_canceland callSendCustomMessage(type=42095). - On success, return
CancelJobResponse{success=true}.
sync lnd_lcp_provider_quote_pay_result
Summary: As a Provider, receivelcp_quote_request, receive the input stream, issue an invoice-bound quote, wait for settlement, stream the result, and send lcp_result.
When: the Provider handler receives lcp_quote_request.
Then:
- If Provider is disabled or the compute backend is disabled, send
lcp_error(code=unsupported_task)(do not create invoices or execute). - Validate
protocol_version; if unsupported, sendlcp_error(code=unsupported_version). - Validate
task_kindandparams; if unsupported, sendlcp_error(code=unsupported_task|unsupported_params). - If
expiry < now, drop; otherwise computeeffective_expiry = min(expiry, now + MAX_ENVELOPE_EXPIRY_WINDOW_SECONDS)and de-duplicate viaReplayStoreuntileffective_expiry(drop duplicates). - Compute
params_hashviaProtocolCompatibility.compute_params_hashand persist a job record inProviderJobStoreasawaiting_input. - Receive and validate exactly one input stream (
stream_kind=input) for thisjob_idvia the Provider stream handlers:- On
lcp_stream_begin: validate required fields (total_len,sha256), enforcecontent_encoding, enforce localmax_stream_bytes/max_job_bytes, and initialize stream state. - On
lcp_stream_chunk: enforceseqordering (ignore duplicates; reject out-of-order), append bytes, and enforce local limits. - On
lcp_stream_end: validate length/hash; on failure sendlcp_error(code=checksum_mismatch)and fail the job.
- On
- Set
quote_expiry = now + quote_ttl_seconds, computeprice_msatviaLLMExecutionPolicy.apply/LLMUsageEstimator.estimate/LLMPricing.quote_price, then computeterms_hash(including input metadata andparams_hash). - Satisfy idempotency for the same
job_id:- If
ProviderJobStorealready has a valid quote, re-send the samelcp_quote_response. - If it exists but is expired, send
lcp_error(code=quote_expired). - If the existing job’s
terms_hashmismatches, sendlcp_error(code=payment_invalid).
- If
- Call
create_invoice(description_hash=terms_hash, price_msat, expiry_seconds)and obtainpayment_request.expiry_seconds = max(1, quote_ttl_seconds - ALLOWED_CLOCK_SKEW_SECONDS)- Rationale: ensure
invoice_expiry_unix <= quote_expiry (+ ALLOWED_CLOCK_SKEW_SECONDS)on the requester side.
- Encode and send
lcp_quote_response, and persist it inProviderJobStore(statewaiting_payment). - Wait for settlement via
wait_for_settlement(MUST NOT start execution before settlement). - After settlement, call
ComputeBackend.executeand obtain output. - Send the result:
- Stream
outputvialcp_stream_begin(stream_kind=result)/lcp_stream_chunk× N /lcp_stream_end. - Send
lcp_result(status=ok)with metadata matching the validated result stream. - If execution fails, send
lcp_result(status=failed, message=...)instead ofstatus=ok.
sync lnd_lcp_provider_cancel
Summary: On receivinglcp_cancel, stop the job as much as possible.
When: the Provider handler receives lcp_cancel.
Then:
- If
expiry < now, drop; if duplicate byReplayStore, drop. - If the job is
awaiting_inputorwaiting_payment, markProviderJobStoreascanceled, stop further work, and SHOULD sendlcp_result(status=cancelled). - If the job is executing, attempt context cancellation and stop as much as possible (best-effort) and SHOULD send
lcp_result(status=cancelled).
sync integration_grpc_smoke
Summary: Ensure gRPC request/response works via an integration test (no external processes). When: the integration test starts. Then:- Server startup succeeds.
- Verify
ListLCPPeersreturns a response (typically an empty list). - Verify
GetLocalInforeturns UNAVAILABLE whenlndis not configured.
sync integration_regtest_lnd_lcp_manifest_roundtrip
Summary: On regtest, show via an integration test (opt-in) that two nodes can exchangelcp_manifest over lnd custom messages.
When: the integration test starts with LCP_ITEST_REGTEST=1.
Then:
- Start
bitcoindand twolndnodes and initialize wallets. - Connect the two nodes via
ConnectPeerand ensure they are online (no channel required). - Start go-lcpd on both sides and begin
lnd_inbound_custom_message_dispatch. - Wait (short timeout) until
ListLCPPeersreturns one peer. - Verify
peer_idequals the remote pubkey and required fields likeremote_manifest.protocol_version==2and stream limit fields match expectations.
sync integration_regtest_lnd_lcp_quote_pay_result_custom_messages
Summary: On regtest, show via an integration test (opt-in) that LCP v0.2 quote → payment → result streaming completes over custom messages. When: the integration test starts withLCP_ITEST_REGTEST=1.
Then:
- Start
bitcoindand twolndnodes (Alice=Requester, Bob=Provider) and initialize wallets. - Open a channel from Alice → Bob (required to settle invoices).
- Start go-lcpd on Bob and enable Provider flows
lnd_inbound_custom_message_dispatchandlnd_lcp_provider_quote_pay_result(compute backend is a deterministic stub). - On Alice (the test Requester), send
lcp_quote_requestand the input stream (lcp_stream_begin/chunk/end) viaSendCustomMessage. - Receive and decode
lcp_quote_responseviaSubscribeCustomMessages, verifydescription_hash == terms_hashanddestination(payee) == bob_pubkeyviaDecodePayReq, then pay. - Receive and decode the result stream (
lcp_stream_begin/chunk/end) andlcp_result, verify stream validation + metadata consistency, and verify the reconstructed output matches expectations (usecmp.Diff).