Skip to content

Commit 5f9e872

Browse files
Harden the smart-account demo and add a copyable token contract
App: resolve the spend-cap decimals at submit time and fail closed when a custom token's decimals cannot be read; format inbox amounts at the per-request token scale; unify the approval report-back; make a confirmed-without-hash approval terminal; propagate CancellationException at the flow and screen boundaries; decode XDR off the composition pass; add a tap-to-copy token contract to the delegate result card; debounce the token decimals lookup. Coordination server: temp-file write cleanup, application logging, body-size limit, cancellation propagation. Reference agent: validate the amount against the SDK and bound the poll settings, restrict escalation to the policy-denial codes, reconcile the coordination wire contract.
1 parent 3f15070 commit 5f9e872

29 files changed

Lines changed: 1055 additions & 335 deletions

File tree

smart-account-demo/coordination-server/src/main/kotlin/com/soneso/smartdemo/coordination/Errors.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ class NotFoundException(override val message: String) : Exception(message)
1616
*/
1717
class ConflictException(override val message: String) : Exception(message)
1818

19+
/**
20+
* Raised when a request body exceeds the accepted size limit. Maps to HTTP 413.
21+
*/
22+
class PayloadTooLargeException(override val message: String) : Exception(message)
23+
1924
/**
2025
* Raised when configuration cannot be resolved into a runnable state.
2126
*/

smart-account-demo/coordination-server/src/main/kotlin/com/soneso/smartdemo/coordination/Models.kt

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,38 +10,30 @@ import kotlinx.serialization.Serializable
1010
* [APPROVED] or [REJECTED]. No other transition is permitted.
1111
*/
1212
@Serializable
13-
enum class RequestStatus {
13+
enum class RequestStatus(
14+
/** The string used on the wire and in persisted JSON. */
15+
val wireName: String,
16+
) {
1417
@SerialName("pending")
15-
PENDING,
18+
PENDING("pending"),
1619

1720
@SerialName("approved")
18-
APPROVED,
21+
APPROVED("approved"),
1922

2023
@SerialName("rejected")
21-
REJECTED;
22-
23-
/** The string used on the wire and in persisted JSON. */
24-
val wireName: String
25-
get() = when (this) {
26-
PENDING -> "pending"
27-
APPROVED -> "approved"
28-
REJECTED -> "rejected"
29-
}
24+
REJECTED("rejected");
3025

3126
companion object {
3227
/**
3328
* Parses a wire value into a [RequestStatus].
3429
*
3530
* Throws [ValidationException] when [value] is not a known status.
3631
*/
37-
fun fromWire(value: String): RequestStatus = when (value) {
38-
"pending" -> PENDING
39-
"approved" -> APPROVED
40-
"rejected" -> REJECTED
41-
else -> throw ValidationException(
42-
"status must be one of 'pending', 'approved', 'rejected'"
43-
)
44-
}
32+
fun fromWire(value: String): RequestStatus =
33+
entries.firstOrNull { it.wireName == value }
34+
?: throw ValidationException(
35+
"status must be one of ${entries.joinToString(", ") { "'${it.wireName}'" }}"
36+
)
4537
}
4638
}
4739

smart-account-demo/coordination-server/src/main/kotlin/com/soneso/smartdemo/coordination/RequestStore.kt

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@ class RequestStore(
3535
/** Insertion order of ids. Reversed when listing so newest appears first. */
3636
private val order = mutableListOf<String>()
3737

38-
/** Path of the backing JSON file, or `null` when persistence is disabled. */
39-
val backingStorePath: String?
40-
get() = storePath
41-
4238
/**
4339
* Loads persisted records when a store path is configured and the file
4440
* exists. Safe to call once during startup.
@@ -170,27 +166,35 @@ class RequestStore(
170166
Files.createDirectories(directory)
171167
}
172168
val temp = Files.createTempFile(directory, "coordination-store", ".tmp")
173-
Files.writeString(temp, data)
174169
try {
175-
Files.move(
176-
temp,
177-
target,
178-
StandardCopyOption.REPLACE_EXISTING,
179-
StandardCopyOption.ATOMIC_MOVE,
180-
)
181-
} catch (_: Exception) {
182-
// ATOMIC_MOVE is unsupported on some filesystems; fall back to a
183-
// replace move so persistence still works for those targets.
184-
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING)
170+
Files.writeString(temp, data)
171+
try {
172+
Files.move(
173+
temp,
174+
target,
175+
StandardCopyOption.REPLACE_EXISTING,
176+
StandardCopyOption.ATOMIC_MOVE,
177+
)
178+
} catch (_: Exception) {
179+
// ATOMIC_MOVE is unsupported on some filesystems; fall back to a
180+
// replace move so persistence still works for those targets.
181+
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING)
182+
}
183+
} catch (e: Exception) {
184+
// A failed write or move leaves the temp file behind; remove it so a
185+
// failed flush does not leak orphaned temp files into the store
186+
// directory, then propagate the failure to the caller.
187+
Files.deleteIfExists(temp)
188+
throw e
185189
}
186190
}
187191
}
188192

189193
private companion object {
190-
val json = Json {
191-
encodeDefaults = true
192-
explicitNulls = true
193-
ignoreUnknownKeys = true
194-
}
194+
// Strict variant of the shared wire codec: persistence reads disable input
195+
// coercion so a corrupt or hand-edited store file fails loudly instead of
196+
// silently coercing malformed values to defaults. Encoding is identical to
197+
// the wire codec, so the persisted JSON matches the wire shape field-for-field.
198+
val json = Json(from = wireJson) { coerceInputValues = false }
195199
}
196200
}

smart-account-demo/coordination-server/src/main/kotlin/com/soneso/smartdemo/coordination/Server.kt

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.ktor.server.application.log
1414
import io.ktor.server.cio.CIO
1515
import io.ktor.server.engine.embeddedServer
1616
import io.ktor.server.plugins.statuspages.StatusPages
17+
import io.ktor.server.request.contentLength
1718
import io.ktor.server.request.header
1819
import io.ktor.server.request.httpMethod
1920
import io.ktor.server.request.path
@@ -23,13 +24,21 @@ import io.ktor.server.response.respondText
2324
import io.ktor.server.routing.get
2425
import io.ktor.server.routing.post
2526
import io.ktor.server.routing.routing
27+
import kotlinx.coroutines.CancellationException
2628
import kotlinx.serialization.KSerializer
2729
import kotlinx.serialization.json.Json
28-
import java.time.Instant
2930

3031
/** `Bearer ` prefix on the Authorization header. */
3132
private const val BEARER_PREFIX = "Bearer "
3233

34+
/**
35+
* Largest request body the API decodes, in bytes. Bodies declaring or carrying
36+
* more than this are rejected with HTTP 413 before any JSON parsing, bounding the
37+
* memory a single request can pin. Generous for the small JSON call descriptors
38+
* this relay handles while still capping abuse.
39+
*/
40+
private const val MAX_REQUEST_BODY_BYTES = 256L * 1024
41+
3342
/**
3443
* JSON codec for the wire contract: nullable fields are always present (as
3544
* `null`), and unknown keys in request bodies are ignored so server-assigned
@@ -38,7 +47,7 @@ private const val BEARER_PREFIX = "Bearer "
3847
* that default rather than rejected, matching the iOS and Dart servers which
3948
* treat a missing or null amount as the empty string.
4049
*/
41-
private val json = Json {
50+
internal val wireJson = Json {
4251
encodeDefaults = true
4352
explicitNulls = true
4453
ignoreUnknownKeys = true
@@ -73,7 +82,14 @@ fun Application.coordinationModule(store: RequestStore, token: String) {
7382
exception<StoreFormatException> { call, cause ->
7483
call.respondError(HttpStatusCode.BadRequest, cause.message)
7584
}
85+
exception<PayloadTooLargeException> { call, cause ->
86+
call.respondError(HttpStatusCode.PayloadTooLarge, cause.message)
87+
}
7688
exception<Throwable> { call, cause ->
89+
// Cancellation is cooperative coroutine control flow (client disconnects,
90+
// shutdown), not a server fault: propagate it untouched so it is not
91+
// logged as an error or masked behind a 500.
92+
if (cause is CancellationException) throw cause
7793
call.application.log.error("Unhandled error", cause)
7894
call.respondError(HttpStatusCode.InternalServerError, "internal server error")
7995
}
@@ -89,16 +105,17 @@ fun Application.coordinationModule(store: RequestStore, token: String) {
89105
}
90106
}
91107

92-
// Request logging: one line per request to stdout. CORS `OPTIONS` preflights are
93-
// short-circuited noise and are not logged.
108+
// Request logging: one line per request through the application logger, which
109+
// adds its own timestamp. CORS `OPTIONS` preflights are short-circuited noise
110+
// and are not logged.
94111
intercept(ApplicationCallPipeline.Monitoring) {
95112
val start = System.nanoTime()
96113
proceed()
97114
if (call.request.httpMethod == HttpMethod.Options) return@intercept
98115
val millis = (System.nanoTime() - start) / 1_000_000
99116
val status = call.response.status()?.value ?: 0
100-
println(
101-
"${Instant.now()} ${call.request.httpMethod.value} ${call.request.path()} $status ${millis}ms"
117+
call.application.log.info(
118+
"${call.request.httpMethod.value} ${call.request.path()} $status ${millis}ms"
102119
)
103120
}
104121

@@ -138,7 +155,7 @@ fun Application.coordinationModule(store: RequestStore, token: String) {
138155
}
139156

140157
post("/requests") {
141-
val input = decodeBody(call.receiveText(), CreateRequestInput.serializer(), allowEmpty = false)
158+
val input = decodeBody(call.receiveBoundedText(), CreateRequestInput.serializer(), allowEmpty = false)
142159
.validated()
143160
val created = store.create(input)
144161
call.respondJson(HttpStatusCode.Created, SmartAccountRequest.serializer(), created)
@@ -163,7 +180,7 @@ fun Application.coordinationModule(store: RequestStore, token: String) {
163180

164181
post("/requests/{id}/approve") {
165182
val id = call.parameters["id"].orEmpty()
166-
val body = decodeBody(call.receiveText(), ApproveBody.serializer(), allowEmpty = false)
183+
val body = decodeBody(call.receiveBoundedText(), ApproveBody.serializer(), allowEmpty = false)
167184
val resultHash = body.resultHash
168185
if (resultHash.isNullOrEmpty()) {
169186
throw ValidationException("field 'resultHash' must be a non-empty string")
@@ -174,7 +191,7 @@ fun Application.coordinationModule(store: RequestStore, token: String) {
174191

175192
post("/requests/{id}/reject") {
176193
val id = call.parameters["id"].orEmpty()
177-
val body = decodeBody(call.receiveText(), RejectBody.serializer(), allowEmpty = true)
194+
val body = decodeBody(call.receiveBoundedText(), RejectBody.serializer(), allowEmpty = true)
178195
val updated = store.reject(id, body.note)
179196
call.respondJson(HttpStatusCode.OK, SmartAccountRequest.serializer(), updated)
180197
}
@@ -190,6 +207,27 @@ fun buildServer(store: RequestStore, token: String, port: Int) =
190207
coordinationModule(store, token)
191208
}
192209

210+
/**
211+
* Reads the request body as text, rejecting anything larger than
212+
* [MAX_REQUEST_BODY_BYTES] with a [PayloadTooLargeException] (HTTP 413).
213+
*
214+
* A declared `Content-Length` over the limit is rejected before the body is read,
215+
* so an oversized request never reaches [receiveText]. Bodies without a usable
216+
* `Content-Length` (e.g. chunked transfer) are checked again after reading, since
217+
* their true size is only known once buffered.
218+
*/
219+
private suspend fun ApplicationCall.receiveBoundedText(): String {
220+
val declaredLength = request.contentLength()
221+
if (declaredLength != null && declaredLength > MAX_REQUEST_BODY_BYTES) {
222+
throw PayloadTooLargeException("request body exceeds the $MAX_REQUEST_BODY_BYTES byte limit")
223+
}
224+
val text = receiveText()
225+
if (text.toByteArray(Charsets.UTF_8).size.toLong() > MAX_REQUEST_BODY_BYTES) {
226+
throw PayloadTooLargeException("request body exceeds the $MAX_REQUEST_BODY_BYTES byte limit")
227+
}
228+
return text
229+
}
230+
193231
/**
194232
* Decodes a JSON object request body into [T].
195233
*
@@ -201,12 +239,12 @@ private fun <T> decodeBody(text: String, serializer: KSerializer<T>, allowEmpty:
201239
val trimmed = text.trim()
202240
if (trimmed.isEmpty()) {
203241
if (allowEmpty) {
204-
return json.decodeFromString(serializer, "{}")
242+
return wireJson.decodeFromString(serializer, "{}")
205243
}
206244
throw ValidationException("request body must be a JSON object")
207245
}
208246
return try {
209-
json.decodeFromString(serializer, trimmed)
247+
wireJson.decodeFromString(serializer, trimmed)
210248
} catch (e: Exception) {
211249
throw ValidationException("request body is not a valid JSON object: ${e.message}")
212250
}
@@ -217,12 +255,12 @@ private suspend fun <T> ApplicationCall.respondJson(
217255
serializer: KSerializer<T>,
218256
value: T,
219257
) {
220-
respondText(json.encodeToString(serializer, value), jsonContentType, status)
258+
respondText(wireJson.encodeToString(serializer, value), jsonContentType, status)
221259
}
222260

223261
private suspend fun ApplicationCall.respondError(status: HttpStatusCode, message: String?) {
224262
respondText(
225-
json.encodeToString(ErrorResponse.serializer(), ErrorResponse(message ?: "error")),
263+
wireJson.encodeToString(ErrorResponse.serializer(), ErrorResponse(message ?: "error")),
226264
jsonContentType,
227265
status,
228266
)

smart-account-demo/coordination-server/src/test/kotlin/com/soneso/smartdemo/coordination/HttpTest.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import kotlinx.serialization.json.putJsonArray
3131
import kotlin.test.Test
3232
import kotlin.test.assertEquals
3333
import kotlin.test.assertNotEquals
34-
import kotlin.test.assertNull
3534
import kotlin.test.assertTrue
3635

3736
private const val TOKEN = "test-token-123"
@@ -215,6 +214,14 @@ class HttpTest {
215214
assertEquals(HttpStatusCode.BadRequest, response.status)
216215
}
217216

217+
@Test
218+
fun returns413WhenTheRequestBodyExceedsTheSizeLimit() = httpTest {
219+
// A body well past the server's 256 KiB cap must be rejected with 413
220+
// before any JSON parsing is attempted.
221+
val oversized = createBody(targetFn = "x".repeat(512 * 1024)).toString()
222+
assertEquals(HttpStatusCode.PayloadTooLarge, post("/requests", oversized).status)
223+
}
224+
218225
// MARK: - GET /requests
219226

220227
@Test

smart-account-demo/reference-agent/src/main/kotlin/com/soneso/smartdemo/agent/AgentConfig.kt

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.soneso.smartdemo.agent
22

33
import com.soneso.stellar.sdk.StrKey
4+
import com.soneso.stellar.sdk.smartaccount.core.ValidationException
5+
import com.soneso.stellar.sdk.smartaccount.oz.OZTransactionOperations
46
import kotlinx.serialization.json.Json
57
import kotlinx.serialization.json.JsonObject
68
import kotlinx.serialization.json.JsonPrimitive
@@ -140,9 +142,9 @@ data class AgentConfig(
140142
if (seed.isNullOrEmpty()) {
141143
throw AgentConfigException("agentSecretSeed is required.")
142144
}
143-
if (seed.length != 64 || !Hex.isHexString(seed)) {
145+
if (!isValidHexSeed(seed)) {
144146
throw AgentConfigException(
145-
"agentSecretSeed is not a valid 64-character hex Ed25519 seed."
147+
"agentSecretSeed is not a valid $AGENT_HEX_KEY_LENGTH-character hex Ed25519 seed."
146148
)
147149
}
148150

@@ -172,6 +174,40 @@ data class AgentConfig(
172174
if (coordinationToken.isEmpty()) {
173175
throw AgentConfigException("coordinationToken is required.")
174176
}
177+
178+
if (tokenDecimals !in 0..OZTransactionOperations.MAX_TOKEN_DECIMALS) {
179+
throw AgentConfigException(
180+
"tokenDecimals must be between 0 and " +
181+
"${OZTransactionOperations.MAX_TOKEN_DECIMALS}, got: $tokenDecimals."
182+
)
183+
}
184+
// The transfer call converts amount to base units with the SDK's
185+
// amountToBaseUnits at this token scale. Run the same conversion here so
186+
// the validator rejects exactly what run() would reject: amounts that are
187+
// not strictly positive and fractions with more digits than tokenDecimals
188+
// allows. This fails fast, before any network or identity work.
189+
try {
190+
OZTransactionOperations.amountToBaseUnits(amount, tokenDecimals)
191+
} catch (e: ValidationException) {
192+
throw AgentConfigException(
193+
"amount is not a valid transfer amount at $tokenDecimals token decimals: " +
194+
(e.message ?: amount)
195+
)
196+
}
197+
198+
// The poll budget must do at least one bounded iteration: a non-positive
199+
// interval would busy-loop the network, and fewer than one attempt would
200+
// create the escalation and immediately abandon it.
201+
if (pollIntervalSeconds <= 0) {
202+
throw AgentConfigException(
203+
"pollIntervalSeconds must be greater than zero, got: $pollIntervalSeconds."
204+
)
205+
}
206+
if (pollMaxAttempts < 1) {
207+
throw AgentConfigException(
208+
"pollMaxAttempts must be at least one, got: $pollMaxAttempts."
209+
)
210+
}
175211
}
176212

177213
/** Redacts the agent seed and bearer token so the config is safe to log. */

smart-account-demo/reference-agent/src/main/kotlin/com/soneso/smartdemo/agent/AgentEd25519Adapter.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,16 @@ class AgentEd25519Adapter : OZExternalEd25519SignerAdapter {
4141
registry[slot] = seedBytes.copyOf()
4242
}
4343

44-
/** Removes every registered seed. */
44+
/**
45+
* Removes every registered seed, zeroing each stored copy first so the seed
46+
* bytes do not linger in the heap after the reference is dropped. The stored
47+
* arrays are private copies (see [add]); zeroing them does not affect the
48+
* caller's seed array.
49+
*/
4550
fun clearAll() {
51+
for (seed in registry.values) {
52+
seed.fill(0)
53+
}
4654
registry.clear()
4755
}
4856

0 commit comments

Comments
 (0)