From ac7b283471a64da036d938f2107843536f7f2c35 Mon Sep 17 00:00:00 2001 From: MFrancis-dev Date: Fri, 16 Oct 2026 21:01:45 +0100 Subject: [PATCH 1/3] chore(bindings): regenerate resolver client stale since #159 packages/bindings/resolver/src/index.ts was missing the #159 interface additions (ResolutionSpecV2, Verdict, Escalation, ExpiryMode, DisputeSettled, expire_dispute/settle_dispute/escalation_attest/ current_round/register_spec_v2, Error variants 19-25). Regenerated with scripts/gen-bindings.sh at stellar-cli 27.1.0; no other package drifted. --- .../packages/bindings/resolver/src/index.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/oracledesk-stellar/packages/bindings/resolver/src/index.ts b/oracledesk-stellar/packages/bindings/resolver/src/index.ts index e73338f..5251a4c 100644 --- a/oracledesk-stellar/packages/bindings/resolver/src/index.ts +++ b/oracledesk-stellar/packages/bindings/resolver/src/index.ts @@ -357,30 +357,46 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAEgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAAS", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAGQAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAASAAAAAAAAABJEZWFkbGluZU5vdFJlYWNoZWQAAAAAABMAAAAAAAAAE1Jlc29sdXRpb25BdmFpbGFibGUAAAAAFAAAAAAAAAAORG9tYWluTWlzbWF0Y2gAAAAAABUAAAAAAAAAClN0YWxlUm91bmQAAAAAABYAAAAAAAAAEVRvb01hbnlTaWduYXR1cmVzAAAAAAAAFwAAAAAAAAATRGlzcHV0ZVdpbmRvd0Nsb3NlZAAAAAAYAAAAAAAAAAlOb0Rpc3B1dGUAAAAAAAAZ", + "AAAAAgAAAD5UaGUgZXNjYWxhdGlvbiBhdXRob3JpdHkncyBydWxpbmcuIGBJbnZhbGlkYCB2b2lkcyB0aGUgbWFya2V0LgAAAAAAAAAAAAdWZXJkaWN0AAAAAAMAAAAAAAAAAAAAAANZZXMAAAAAAAAAAAAAAAACTm8AAAAAAAAAAAAAAAAAB0ludmFsaWQA", + "AAAABQAAAAAAAAAAAAAACERpc3B1dGVkAAAAAQAAAAhkaXNwdXRlZAAAAAQAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAhkaXNwdXRlcgAAABMAAAAAAAAAAAAAAARib25kAAAACwAAAAAAAABJV2hhdCB0aGUgZGlzcHV0ZXIgY2xhaW1zIGlzIGNvcnJlY3Q7IGBOb25lYCBjbGFpbXMgdGhlIG1hcmtldCBpcyBpbnZhbGlkLgAAAAAAAAthbHRlcm5hdGl2ZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", + "AAAAAgAAABdXaG8gcnVsZXMgb24gYSBkaXNwdXRlLgAAAAAAAAAACkVzY2FsYXRpb24AAAAAAAIAAAAAAAAALVRoZSByZXNvbHZlciBhZG1pbiwgdGhyb3VnaCBgc2V0dGxlX2Rpc3B1dGVgLgAAAAAAAAhHdWFyZGlhbgAAAAEAAABfYGtgIG9mIHRoZXNlIG1lbWJlcnMsIHRocm91Z2ggYGVzY2FsYXRpb25fYXR0ZXN0YC4gTXVzdCBiZSBkaXNqb2ludApmcm9tIHRoZSBwcm9wb3Npbmcgc2lnbmVycy4AAAAACUNvbW1pdHRlZQAAAAAAAAIAAAPqAAAAEwAAAAQ=", + "AAAAAgAAADZXaGljaCByZXNvbHV0aW9uIHBhdGggYSBtYXJrZXQgd2FzIG9uIHdoZW4gaXQgZXhwaXJlZC4AAAAAAAAAAAAKRXhwaXJ5TW9kZQAAAAAAAwAAAAAAAAAAAAAAClVucmV2ZWFsZWQAAAAAAAAAAAAAAAAABVByaWNlAAAAAAAAAAAAAAAAAAAHU2lnbmVycwA=", "AAAAAgAAAAAAAAAAAAAAC09yYWNsZUFzc2V0AAAAAAIAAAABAAAAAAAAAAdTdGVsbGFyAAAAAAEAAAATAAAAAQAAAAAAAAAFT3RoZXIAAAAAAAABAAAAEQ==", "AAAAAQAAAAAAAAAAAAAAC1ByaWNlQ29uZmlnAAAAAAUAAAAAAAAABWFzc2V0AAAAAAAH0AAAAAtPcmFjbGVBc3NldAAAAAAAAAAACWRpcmVjdGlvbgAAAAAAB9AAAAAOUHJpY2VEaXJlY3Rpb24AAAAAAAAAAAANbWF4X3N0YWxlbmVzcwAAAAAAAAYAAAAAAAAACXJlZmxlY3RvcgAAAAAAABMAAAAAAAAACXRocmVzaG9sZAAAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAC1dlaWdodGVkS2V5AAAAAAIAAACCUmF3IEVkMjU1MTkgcHVibGljIGtleS4gRm9yIGEgYEcuLi5gIGFjY291bnQgYWRkcmVzcyB0aGlzIGlzCmBTdHJLZXkuZGVjb2RlRWQyNTUxOVB1YmxpY0tleShhZGRyZXNzKWA7IGBDLi4uYCBhZGRyZXNzZXMgaGF2ZSBub25lLgAAAAAABnB1YmtleQAAAAAD7gAAACAAAAAAAAAABndlaWdodAAAAAAABA==", "AAAAAQAAAAAAAAAAAAAADFNpZ25lckNvbmZpZwAAAAMAAAAAAAAADmRpc3B1dGVfd2luZG93AAAAAAAGAAAAAAAAAAdzaWduZXJzAAAAA+oAAAATAAAAAAAAAAl0aHJlc2hvbGQAAAAAAAAE", "AAAABQAAAAAAAAAAAAAADU1hcmtldEV4cGlyZWQAAAAAAAABAAAADm1hcmtldF9leHBpcmVkAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAACGRlYWRsaW5lAAAABgAAAAAAAAAAAAAABG1vZGUAAAfQAAAACkV4cGlyeU1vZGUAAAAAAAAAAAAC", "AAAAAgAAAAAAAAAAAAAADlByaWNlRGlyZWN0aW9uAAAAAAAEAAAAAAAAAAAAAAAFQWJvdmUAAAAAAAAAAAAAAAAAAAlBdE9yQWJvdmUAAAAAAAAAAAAAAAAAAAVCZWxvdwAAAAAAAAAAAAAAAAAACUF0T3JCZWxvdwAAAA==", - "AAAAAgAAAAAAAAAAAAAAD1Jlc29sdXRpb25TdGF0ZQAAAAADAAAAAAAAAAAAAAAMVW5jb25maWd1cmVkAAAAAAAAAAAAAAAOU2lnbmVyc1BlbmRpbmcAAAAAAAAAAAAAAAAACUZpbmFsaXplZAAAAA==", + "AAAAAgAAARNUaGUgZnVsbCByZXNvbHV0aW9uIHJ1bGUgYSBtYXJrZXQncyBgcmVzb2x1dGlvbl9oYXNoYCBjb21taXRzIHRvLiBIYXNoaW5nCnRoaXMgKHJhdGhlciB0aGFuIHRoZSBiYXJlIGBQcmljZUNvbmZpZ2AvYFNpZ25lckNvbmZpZ2ApIGtlZXBzIHRoZSB0d28KbW9kZXMgaW4gZGlzam9pbnQgaGFzaCBzcGFjZXMgZXZlbiBpZiB0aGVpciBmaWVsZCBlbmNvZGluZ3MgY291bGQKb3RoZXJ3aXNlIGNvbGxpZGUuIFNlZSBgZG9jcy9hZHIvMDAwMS1yZXNvbHV0aW9uLWNvbW1pdG1lbnQubWRgLgAAAAAAAAAADlJlc29sdXRpb25TcGVjAAAAAAAEAAAAAQAAAAAAAAAFUHJpY2UAAAAAAAABAAAH0AAAAAtQcmljZUNvbmZpZwAAAAABAAAAAAAAAAdTaWduZXJzAAAAAAEAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAAAAAAADFdlaWdodGVkS2V5cwAAAAEAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAAAAAAAARU2lnbmVyc0Rpc3B1dGFibGUAAAAAAAABAAAH0AAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAA=", + "AAAABQAAAVlgZGlzcHV0ZXJfd29uYCBpcyB0cnVlIHdoZW4gdGhlIGJvbmQgcmV0dXJucyB0byB0aGUgZGlzcHV0ZXIgYmVjYXVzZSB0aGUKcnVsaW5nIG92ZXJ0dXJuZWQgdGhlIHByb3Bvc2FsLiBBIHRpbWVvdXQgKGBleHBpcmVfZGlzcHV0ZWApIGFsc28gcmVmdW5kcwp0aGUgYm9uZCBidXQgaXMgcmVwb3J0ZWQgYXMgYHZlcmRpY3Q6IEludmFsaWQsIGRpc3B1dGVyX3dvbjogZmFsc2VgOiBhCnJ1bGluZyBvZiBgSW52YWxpZGAgYWx3YXlzIG92ZXJ0dXJucywgc28gdGhhdCBwYWlyIGlkZW50aWZpZXMgYSB0aW1lb3V0LgpgcGF5b3V0YCBpcyB0aGUgZXNjcm93ZWQgYm9uZCwgY2xhaW1hYmxlIGJ5IGl0cyBwYXllZS4AAAAAAAAAAAAADkRpc3B1dGVTZXR0bGVkAAAAAAABAAAAD2Rpc3B1dGVfc2V0dGxlZAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAAAAAAAAAAAMZGlzcHV0ZXJfd29uAAAAAQAAAAAAAAAAAAAABnBheW91dAAAAAAACwAAAAAAAAAC", + "AAAAAgAAAAAAAAAAAAAAD1Jlc29sdXRpb25TdGF0ZQAAAAAFAAAAAAAAAAAAAAAMVW5jb25maWd1cmVkAAAAAAAAAAAAAAAOU2lnbmVyc1BlbmRpbmcAAAAAAAAAAAAAAAAACUZpbmFsaXplZAAAAAAAAAAAAABEVm9pZGVkIGJ5IFtgUmVzb2x2ZXI6OmV4cGlyZV9tYXJrZXRgXSBhZnRlciB0aGUgcmVzb2x1dGlvbiBkZWFkbGluZS4AAAAHRXhwaXJlZAAAAAAAAAAAQkEgYm9uZGVkIGRpc3B1dGUgaXMgb3BlbjsgZmluYWxpemF0aW9uIGlzIGZyb3plbiB1bnRpbCBpdCBzZXR0bGVzLgAAAAAACERpc3B1dGVk", "AAAABQAAAAAAAAAAAAAAD1ByaWNlUmVzb2x1dGlvbgAAAAABAAAAEHByaWNlX3Jlc29sdXRpb24AAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFcHJpY2UAAAAAAAALAAAAAAAAAAAAAAAPcHJpY2VfdGltZXN0YW1wAAAAAAYAAAAAAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAg==", + "AAAAAQAAAOBWZXJzaW9uZWQgY29tbWl0bWVudCBlbnZlbG9wZS4gVW5saWtlIGEgYmFyZSBbYFJlc29sdXRpb25TcGVjYF0gaXQgYmluZHMgdGhlCm5ldHdvcmsgYW5kIHRoZSBtYXJrZXQtY29yZSBpbnN0YW5jZSwgYW5kIGNhcnJpZXMgYSBjcmVhdG9yLWNob3NlbiBgc2FsdGAKdGhhdCBtYWtlcyBhIHByZS1yZXZlYWwgYnJ1dGUtZm9yY2Ugb2YgdGhlIChsb3ctZW50cm9weSkgcnVsZSBpbmZlYXNpYmxlLgAAAAAAAAAQUmVzb2x1dGlvblNwZWNWMgAAAAUAAAAAAAAAC21hcmtldF9jb3JlAAAAABMAAAA/YHNoYTI1NihuZXR3b3JrIHBhc3NwaHJhc2UpYCwgaS5lLiBgZW52LmxlZGdlcigpLm5ldHdvcmtfaWQoKWAuAAAAAApuZXR3b3JrX2lkAAAAAAPuAAAAIAAAAAAAAAAEcnVsZQAAB9AAAAAOUmVzb2x1dGlvblNwZWMAAAAAAAAAAAAEc2FsdAAAA+4AAAAgAAAAAAAAAAd2ZXJzaW9uAAAAAAQ=", + "AAAAAQAAAAAAAAAAAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAAB3NpZ25lcnMAAAAD6gAAB9AAAAALV2VpZ2h0ZWRLZXkAAAAAd1RvdGFsIHdlaWdodCB0aGF0IG11c3Qgc2lnbiBvbmUgb3V0Y29tZS4gQ2hvb3NlIGl0IGFib3ZlIGhhbGYgdGhlIHRvdGFsCndlaWdodCBzbyB0d28gb3V0Y29tZXMgY2FuIG5ldmVyIGJvdGggcmVhY2ggaXQuAAAAABB3ZWlnaHRfdGhyZXNob2xkAAAABA==", "AAAABQAAAAAAAAAAAAAAElJlc29sdXRpb25Qcm9wb3NlZAAAAAAAAQAAABNyZXNvbHV0aW9uX3Byb3Bvc2VkAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAtwcm9wb3NlZF9hdAAAAAAGAAAAAAAAAAI=", - "AAAAAAAAAIRQYXlzIGEgc2V0dGxlZCBkaXNwdXRlJ3MgYm9uZCB0byBpdHMgcGF5ZWUgKHRoZSBkaXNwdXRlciwgb3IgdGhlCm1hcmtldC1jb3JlIHRyZWFzdXJ5IHdoZW4gdGhlIHByb3Bvc2FsIHdhcyB1cGhlbGQpLiBQZXJtaXNzaW9ubGVzcy4AAAASY2xhaW1fZGlzcHV0ZV9ib25kAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", "AAAABQAAAAAAAAAAAAAAE1Jlc29sdXRpb25GaW5hbGl6ZWQAAAAAAQAAABRyZXNvbHV0aW9uX2ZpbmFsaXplZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAxmaW5hbGl6ZWRfYXQAAAAGAAAAAAAAAAI=", + "AAAAAQAAANBBdHRlc3RlZCByZXNvbHV0aW9uIHdob3NlIHByb3Bvc2FsIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIGR1cmluZwp0aGUgZGlzcHV0ZSB3aW5kb3cuIEhhc2hlZCBhcyBgUmVzb2x1dGlvblNwZWM6OlNpZ25lcnNEaXNwdXRhYmxlYCwgYQpzZXBhcmF0ZSB2YXJpYW50LCBzbyBleGlzdGluZyBgU2lnbmVyc2AgY29tbWl0bWVudHMgYXJlIHVuYWZmZWN0ZWQuAAAAAAAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAAAAAAGAAAAM0NvbGxhdGVyYWwgZXNjcm93ZWQgYnkgYGRpc3B1dGVgOyBtdXN0IGJlIHBvc2l0aXZlLgAAAAAMZGlzcHV0ZV9ib25kAAAACwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAACmVzY2FsYXRpb24AAAAAB9AAAAAKRXNjYWxhdGlvbgAAAAAAQ1NlY29uZHMgYWZ0ZXIgYSBkaXNwdXRlIG9wZW5zIGJlZm9yZSBhbnlvbmUgbWF5IHZvaWQgaXQgdW5yZXNvbHZlZC4AAAAAEmVzY2FsYXRpb25fdGltZW91dAAAAAAABgAAAAAAAAAHc2lnbmVycwAAAAPqAAAAEwAAAAAAAAAJdGhyZXNob2xkAAAAAAAABA==", "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", "AAAAAAAAAAAAAAAFc3RhdGUAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAB9AAAAAPUmVzb2x1dGlvblN0YXRlAA==", "AAAAAAAAAAAAAAAGYXR0ZXN0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAi5DaGFsbGVuZ2VzIHRoZSBjdXJyZW50IHByb3Bvc2FsIG9mIGEgYFNpZ25lcnNEaXNwdXRhYmxlYCBtYXJrZXQgd2hpbGUKaXRzIGRpc3B1dGUgd2luZG93IGlzIHN0aWxsIG9wZW4gKHN0cmljdGx5IGJlZm9yZSBgcHJvcG9zZWRfYXQgKwpkaXNwdXRlX3dpbmRvd2AsIHRoZSBpbnN0YW50IGBmaW5hbGl6ZV9zaWduZXJzYCBiZWNvbWVzIGNhbGxhYmxlKS4gVGhlCmRpc3B1dGVyIGVzY3Jvd3MgYGRpc3B1dGVfYm9uZGAgb2YgdGhlIGNvbGxhdGVyYWwgdG9rZW4sIHdoaWNoIGZyZWV6ZXMKZmluYWxpemF0aW9uIHVudGlsIHRoZSBlc2NhbGF0aW9uIGF1dGhvcml0eSBydWxlcyBvcgpgZXNjYWxhdGlvbl90aW1lb3V0YCBsYXBzZXMuIGBhbHRlcm5hdGl2ZWAgaXMgdGhlIG91dGNvbWUgdGhlIGRpc3B1dGVyCmNsYWltcyBpcyBjb3JyZWN0IChgTm9uZWA6IHRoZSBtYXJrZXQgaXMgaW52YWxpZCk7IGl0IG9ubHkgaW5mb3JtcyB0aGUKcnVsaW5nIGFuZCBtdXN0IGRpZmZlciBmcm9tIHRoZSBwcm9wb3NhbC4gVGhlIGRpc3B1dGVyIG1heSBhbHNvIGJlIGEKcHJvcG9zaW5nIHNpZ25lci4AAAAAAAdkaXNwdXRlAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAACGRpc3B1dGVyAAAAEwAAAAAAAAALYWx0ZXJuYXRpdmUAAAAD6AAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAA=", + "AAAAAAAAAIFDdXJyZW50IGF0dGVzdGF0aW9uIHJvdW5kIG9mIGEgd2VpZ2h0ZWQta2V5IG1hcmtldDsgdGhlIGByb3VuZGAKYXJndW1lbnQgb2YgYHN1Ym1pdF9zaWduYXR1cmVzYCBhbmQgcGFydCBvZiBldmVyeSBzaWduZWQgbWVzc2FnZS4AAAAAAAANY3VycmVudF9yb3VuZAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAABA==", + "AAAAAAAAAfdQZXJtaXNzaW9ubGVzcyBsaXZlbmVzcyBmYWxsYmFjazogb25jZSBgcmVzb2x1dGlvbl9kZWFkbGluZWAgaGFzIHBhc3NlZCwKYW55b25lIG1heSB2b2lkIGEgbWFya2V0IHRoYXQgaXMgc3RpbGwgdW5yZXNvbHZlZCwgc28gY29sbGF0ZXJhbCBpcwpuZXZlciBsb2NrZWQgYmVoaW5kIGEgbWlzc2luZyByZXZlYWwsIGEgZGVhZCBmZWVkIG9yIHNpbGVudCBzaWduZXJzLgoKUmVzb2x1dGlvbiBpcyBwcmVmZXJyZWQgb3ZlciB2b2lkaW5nLiBBIHByaWNlIG1hcmtldCB3aG9zZSBmZWVkIGlzCnZhbGlkIHJpZ2h0IG5vdyBpcyAqcmVzb2x2ZWQqIGluc3RlYWQgKHNvIGEgc3RhbGxlZCBjYWxsZXIgY2FuIG5ldmVyCmZvcmNlIGEgdm9pZCBvbiBhIHJlc29sdmFibGUgbWFya2V0KSwgYW5kIGEgbWFya2V0IHdpdGggYSBwZW5kaW5nCnNpZ25lciBwcm9wb3NhbCBpcyByZWZ1c2VkIHdpdGggYFJlc29sdXRpb25BdmFpbGFibGVgIHVudGlsCmBmaW5hbGl6ZV9zaWduZXJzYCBzZXR0bGVzIGl0LgAAAAANZXhwaXJlX21hcmtldAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAAAAAAANcmVzb2x2ZV9wcmljZQAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", - "AAAAAAAAAMVHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIFZvdGluZwpjYW4gcmVzdGFydCBvbmNlIHRoZSBjb3VudHMgYXJlIGNsZWFyZWQgYnkgcmUtYXR0ZXN0aW5nLgAAAAAAAA9jYW5jZWxfcHJvcG9zYWwAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAMFQZXJtaXNzaW9ubGVzcyBmYWxsYmFjayB3aGVuIHRoZSBlc2NhbGF0aW9uIGF1dGhvcml0eSBuZXZlciBydWxlczoKYGVzY2FsYXRpb25fdGltZW91dGAgYWZ0ZXIgdGhlIGRpc3B1dGUgb3BlbmVkLCB0aGUgYm9uZCBpcyByZWZ1bmRlZCBhbmQKdGhlIG1hcmtldCBpcyB2b2lkZWQsIHNvIG5laXRoZXIgc2lkZSB3aW5zIGJ5IGRlZmF1bHQuAAAAAAAADmV4cGlyZV9kaXNwdXRlAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAADhHdWFyZGlhbiBlc2NhbGF0aW9uOiB0aGUgYWRtaW4gcnVsZXMgb24gYW4gb3BlbiBkaXNwdXRlLgAAAA5zZXR0bGVfZGlzcHV0ZQAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAURHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIEZvcgphZGRyZXNzLWJhc2VkIHNpZ25lcnMgdm90aW5nIGNhbiByZXN0YXJ0IG9uY2UgdGhlIGNvdW50cyBhcmUgY2xlYXJlZCBieQpyZS1hdHRlc3Rpbmc7IGEgd2VpZ2h0ZWQta2V5IG1hcmtldCBtb3ZlcyB0byBhIGZyZXNoIHJvdW5kLCB3aGljaCBtYWtlcwpldmVyeSBzaWduYXR1cmUgY29sbGVjdGVkIHNvIGZhciB1bnVzYWJsZS4AAAAPY2FuY2VsX3Byb3Bvc2FsAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAAAAAAAQZmluYWxpemVfc2lnbmVycwAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", + "AAAAAAAAAWlSZXZlYWxzIGEgcnVsZSBjb21taXR0ZWQgdG8gdGhyb3VnaCBhIFtgUmVzb2x1dGlvblNwZWNWMmBdIGVudmVsb3BlLgpUaGUgZW52ZWxvcGUgbXVzdCBuYW1lIHRoaXMgbmV0d29yayBhbmQgdGhpcyByZXNvbHZlcidzIG1hcmtldC1jb3JlCihgRG9tYWluTWlzbWF0Y2hgKSwgdXNlIGEgbm9uLXplcm8gc2FsdCBhbmQgdGhlIGN1cnJlbnQgYHZlcnNpb25gCihgSW52YWxpZENvbmZpZ2ApLCBhbmQgaGFzaCB0byB0aGUgbWFya2V0J3MgY29tbWl0bWVudAooYFNwZWNIYXNoTWlzbWF0Y2hgKS4gVGhlIGlubmVyIHJ1bGUgaXMgdGhlbiB2YWxpZGF0ZWQgYW5kIHN0b3JlZApleGFjdGx5IGFzIHRoZSBsZWdhY3kgZW50cnlwb2ludHMgZG8uAAAAAAAAEHJlZ2lzdGVyX3NwZWNfdjIAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAARzcGVjAAAH0AAAABBSZXNvbHV0aW9uU3BlY1YyAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAGhDb21taXR0ZWUgZXNjYWxhdGlvbjogZWFjaCBtZW1iZXIgdm90ZXMgb25jZTsgdGhlIGZpcnN0IHZlcmRpY3QgdG8KZ2F0aGVyIGBrYCB2b3RlcyBzZXR0bGVzIHRoZSBkaXNwdXRlLgAAABFlc2NhbGF0aW9uX2F0dGVzdAAAAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABm1lbWJlcgAAAAAAEwAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAhRDb3VudHMgRWQyNTUxOSBzaWduYXR1cmVzIGZyb20gYSB3ZWlnaHRlZCBjb21taXR0ZWUuIFBlcm1pc3Npb25sZXNzOiB0aGUKc2lnbmF0dXJlcywgbm90IHRoZSBzdWJtaXR0ZXIsIGNhcnJ5IHRoZSBhdXRob3JpdHkuIEVhY2ggc2lnbmF0dXJlIGlzCm92ZXIgdGhlIG1lc3NhZ2UgYnVpbHQgYnkgYGF0dGVzdGF0aW9uX21lc3NhZ2VgOyBub24tbWVtYmVycyBhbmQga2V5cwp0aGF0IGFscmVhZHkgc2lnbmVkIHRoaXMgcm91bmQgKGZvciBlaXRoZXIgb3V0Y29tZSkgYXJlIHNraXBwZWQsIGFuZCBhCnNpbmdsZSBpbnZhbGlkIHNpZ25hdHVyZSBhYm9ydHMgdGhlIHdob2xlIGJhdGNoLCBzbyBzdWJtaXR0ZXJzIHNob3VsZApwcmUtZmlsdGVyIHdpdGggYSB2ZXJpZmllci4gT25jZSB0aGUgYWNjdW11bGF0ZWQgd2VpZ2h0IGZvciBgb3V0Y29tZWAKZmlyc3QgcmVhY2hlcyB0aGUgdGhyZXNob2xkIGEgcHJvcG9zYWwgaXMgcmVjb3JkZWQgYW5kIHRoZSBkaXNwdXRlCndpbmRvdyBzdGFydHM7IGxhdGVyIGNhbGxzIG5ldmVyIG1vdmUgaXQuAAAAEXN1Ym1pdF9zaWduYXR1cmVzAAAAAAAABAAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAAVyb3VuZAAAAAAAAAQAAAAAAAAABHNpZ3MAAAPqAAAD7QAAAAIAAAPuAAAAIAAAA+4AAABAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAIRQYXlzIGEgc2V0dGxlZCBkaXNwdXRlJ3MgYm9uZCB0byBpdHMgcGF5ZWUgKHRoZSBkaXNwdXRlciwgb3IgdGhlCm1hcmtldC1jb3JlIHRyZWFzdXJ5IHdoZW4gdGhlIHByb3Bvc2FsIHdhcyB1cGhlbGQpLiBQZXJtaXNzaW9ubGVzcy4AAAASY2xhaW1fZGlzcHV0ZV9ib25kAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAATpSZXZlYWxzIHRoZSByZXNvbHV0aW9uIHJ1bGUgY29tbWl0dGVkIHRvIGF0IG1hcmtldCBjcmVhdGlvbi4gQW55b25lCm1heSBjYWxsIHRoaXMgKHRoZXJlIGlzIG5vIGFkbWluIGdhdGUpIGJlY2F1c2UgdGhlIG9ubHkgdGhpbmcgdGhhdAptYWtlcyBhIHNwZWMgYWNjZXB0YWJsZSBpcyB0aGF0IGl0IGhhc2hlcyB0byBgbWFya2V0LnJlc29sdXRpb25faGFzaGAK4oCUIHRoZSBjb21taXRtZW50LCBub3QgdGhlIGNhbGxlciwgaXMgdGhlIGF1dGhvcml0eS4gRmFpbHMgaWYgYSBzcGVjCndhcyBhbHJlYWR5IHJlZ2lzdGVyZWQgZm9yIHRoaXMgbWFya2V0LgAAAAAAE3JlZ2lzdGVyX3ByaWNlX3NwZWMAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAALUHJpY2VDb25maWcAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAL1XaGVuIGBleHBpcmVfbWFya2V0YCBiZWNvbWVzIGNhbGxhYmxlIGZvciBgbWFya2V0X2lkYDogYGNsb3NlX3RpbWVgIHBsdXMKW2BSRVNPTFVUSU9OX0dSQUNFX1NFQ1NgXS4gTmVlZHMgb25seSBtYXJrZXQtY29yZSdzIGBjbG9zZV90aW1lYCwgc28gaXQKaXMga25vd24gYmVmb3JlIChhbmQgd2l0aG91dCkgYSBzcGVjIHJldmVhbC4AAAAAAAATcmVzb2x1dGlvbl9kZWFkbGluZQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==", "AAAAAAAAAMJHdWFyZGlhbiBhY3Rpb246IHZvaWRzIGFuIHVucmVzb2x2YWJsZSBtYXJrZXQgZGlyZWN0bHkgdGhyb3VnaAptYXJrZXQtY29yZS4gVGhlIHJlc29sdmVyIGlzIHRoZSBkaXJlY3QgY2FsbGVyLCBzbyBtYXJrZXQtY29yZSdzCmByZXF1aXJlX3Jlc29sdmVyYCBjaGVjayBwYXNzZXMgdGhlIHNhbWUgd2F5IGByZXNvbHZlX3ByaWNlYCBkb2VzLgAAAAAAFGd1YXJkaWFuX3ZvaWRfbWFya2V0AAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAENTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUuAAAAABRyZWdpc3Rlcl9zaWduZXJfc3BlYwAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAPpAAAAAgAAAAM=", - "AAAAAQAAANBBdHRlc3RlZCByZXNvbHV0aW9uIHdob3NlIHByb3Bvc2FsIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIGR1cmluZwp0aGUgZGlzcHV0ZSB3aW5kb3cuIEhhc2hlZCBhcyBgUmVzb2x1dGlvblNwZWM6OlNpZ25lcnNEaXNwdXRhYmxlYCwgYQpzZXBhcmF0ZSB2YXJpYW50LCBzbyBleGlzdGluZyBgU2lnbmVyc2AgY29tbWl0bWVudHMgYXJlIHVuYWZmZWN0ZWQuAAAAAAAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAAAAAAGAAAAM0NvbGxhdGVyYWwgZXNjcm93ZWQgYnkgYGRpc3B1dGVgOyBtdXN0IGJlIHBvc2l0aXZlLgAAAAAMZGlzcHV0ZV9ib25kAAAACwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAACmVzY2FsYXRpb24AAAAAB9AAAAAKRXNjYWxhdGlvbgAAAAAAQ1NlY29uZHMgYWZ0ZXIgYSBkaXNwdXRlIG9wZW5zIGJlZm9yZSBhbnlvbmUgbWF5IHZvaWQgaXQgdW5yZXNvbHZlZC4AAAAAEmVzY2FsYXRpb25fdGltZW91dAAAAAAABgAAAAAAAAAHc2lnbmVycwAAAAPqAAAAEwAAAAAAAAAJdGhyZXNob2xkAAAAAAAABA==", "AAAAAAAAAIFTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUgd2hvc2UKcHJvcG9zYWxzIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIChzZWUgYGRpc3B1dGVgKS4AAAAAAAAYcmVnaXN0ZXJfZGlzcHV0YWJsZV9zcGVjAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAAWRGlzcHV0YWJsZVNpZ25lckNvbmZpZwAAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAI5TZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgd2VpZ2h0ZWQgRWQyNTUxOQpjb21taXR0ZWUgdGhhdCBzaWducyBvZmYtY2hhaW4gYW5kIGlzIHZlcmlmaWVkIGJ5IGBzdWJtaXRfc2lnbmF0dXJlc2AuAAAAAAAacmVnaXN0ZXJfd2VpZ2h0ZWRfa2V5X3NwZWMAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAA+kAAAACAAAAAw==" ]), options From 0a5b5d99e3b760f211fc0fc047892b3d01907222 Mon Sep 17 00:00:00 2001 From: MFrancis-dev Date: Fri, 16 Oct 2026 22:27:46 +0100 Subject: [PATCH 2/3] feat(market-core,resolver,treasury): categorical, scalar and dynamic-fee markets (#6 #7 #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - market-core: n-outcome categorical FPMM (2..=8) — create_categorical_market, buy_cat/sell_cat, resolve_cat, redeem_cat, claim_pool_remainder_cat, get_cat_market/get_cat_position/get_prices_cat/quote_buy_cat, new math_categorical module, CatTrade/CatMarketResolved events, MarketKind dispatch - market-core: scalar range markets — create_scalar_market, resolve_scalar (MarketStatus::ResolvedScalar), get_scalar_meta; binary pool reused (LONG=YES) - market-core: dynamic fee schedules — FeeSchedule + effective_fee_bps, create_market_with_fees, set_default_fee_schedule, current_fee_bps, quote_buy_at, TradeV2 carrying the realised fee_bps - resolver: register_cat_signer_spec/attest_cat/finalize_cat and register_scalar_spec/resolve_scalar_price (Reflector, clamped, floored) - treasury: agent_buy_cat under the existing caps - mirrors: model/fpmm_model.py + agents/core/fpmm.ts pinned to shared model/fee_fixtures.json; trader reads get_market + current_fee_bps in live mode - bindings regenerated (market-core/resolver/treasury); vendored market_core.wasm rebuilt via make build - docs: ADR 0005 (market-kind storage split), ADR 0001 appended-variants note, architecture/frontend-integration/STATUS updates --- oracledesk-stellar/.env.example | 4 + oracledesk-stellar/agents/bin/trader.test.ts | 49 +- oracledesk-stellar/agents/bin/trader.ts | 45 +- oracledesk-stellar/agents/core/fpmm.test.ts | 265 ++ oracledesk-stellar/agents/core/fpmm.ts | 237 ++ oracledesk-stellar/agents/stellar/adapter.ts | 33 + .../contracts/market-core/src/lib.rs | 2441 ++++++++++++++++- .../contracts/market-core/src/math.rs | 315 +++ .../market-core/src/math_categorical.rs | 605 ++++ .../contracts/resolver/market_core.wasm | Bin 41999 -> 76343 bytes .../contracts/resolver/src/lib.rs | 1057 ++++++- .../contracts/treasury/market_core.wasm | Bin 41999 -> 76343 bytes .../contracts/treasury/src/lib.rs | 107 +- oracledesk-stellar/docs/STATUS.md | 46 +- .../docs/adr/0001-resolution-commitment.md | 8 + .../adr/0005-market-kind-storage-split.md | 116 + oracledesk-stellar/docs/architecture.md | 129 +- .../docs/frontend-integration.md | 44 + oracledesk-stellar/model/fee_fixtures.json | 15 + oracledesk-stellar/model/fpmm_model.py | 419 +++ .../bindings/market-core/src/index.ts | 372 ++- .../packages/bindings/resolver/src/index.ts | 139 +- .../packages/bindings/treasury/src/index.ts | 11 + .../scripts/spec-hash/src/lib.rs | 142 +- 24 files changed, 6461 insertions(+), 138 deletions(-) create mode 100644 oracledesk-stellar/contracts/market-core/src/math_categorical.rs create mode 100644 oracledesk-stellar/docs/adr/0005-market-kind-storage-split.md create mode 100644 oracledesk-stellar/model/fee_fixtures.json diff --git a/oracledesk-stellar/.env.example b/oracledesk-stellar/.env.example index 24b2e3d..51a258d 100644 --- a/oracledesk-stellar/.env.example +++ b/oracledesk-stellar/.env.example @@ -43,6 +43,10 @@ TESTNET_FACILITATOR_API_KEY= # --- Agents (agents/) ------------------------------------------------------- # "dry-run" (default, never signs or submits) or "live" (real testnet txs). AGENT_MODE=dry-run +# Market the trader reads and trades in live mode (`get_market` plus +# `current_fee_bps`; the effective fee — dynamic schedules included — feeds +# the strategy's quote and impact estimates). +MARKET_ID=0 # Optional: an off-chain reference price source for the trader's fair-value # estimate. Read-only; never used for execution (see docs/architecture.md). POLYMARKET_READ_ONLY_API_URL= diff --git a/oracledesk-stellar/agents/bin/trader.test.ts b/oracledesk-stellar/agents/bin/trader.test.ts index 3576a15..2d2d4b8 100644 --- a/oracledesk-stellar/agents/bin/trader.test.ts +++ b/oracledesk-stellar/agents/bin/trader.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -53,3 +53,50 @@ test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => { assert.equal(args.min_shares_out, (QUOTE_SHARES * 9_900n) / 10_000n); assert.equal(args.deadline, 2_000_030n); }); + +test("live mode reads the market and its effective fee from the chain", async () => { + const traceDir = await mkdtemp(join(tmpdir(), "trader-test-")); + const env = { TRACE_STORE_DIR: traceDir, MARKET_ID: "7" }; + const reads: Array<[string, bigint]> = []; + const adapter = { + mode: "live" as const, + quoteBuy: async () => QUOTE_SHARES, + agentBuyV2: async (args: Record) => ({ + dryRun: true as const, + contractId: "CTREASURY", + method: "agent_buy_v2", + args, + }), + getMarket: async (args: { market_id: bigint }) => { + reads.push(["get_market", args.market_id]); + return { + close_time: 2_000_000n, + fee_bps: 100, // the flat snapshot; the schedule may charge more + reserve_yes: 1_000_000_000_000_000n, + reserve_no: 1_000_000_000_000_000n, + }; + }, + currentFeeBps: async (args: { market_id: bigint }) => { + reads.push(["current_fee_bps", args.market_id]); + return 321; // the dynamic effective fee at the current ledger time + }, + } as unknown as StellarAdapter; + + await main(adapter, env, () => 1_000_000); + + assert.deepEqual(reads, [ + ["get_market", 7n], + ["current_fee_bps", 7n], + ]); + + // The trace records the live market state, including the effective fee + // (not the flat snapshot) and the reserves the quote was sized against. + const traces = await readdir(traceDir); + assert.equal(traces.length, 1); + const trace = JSON.parse(await readFile(join(traceDir, traces[0]), "utf8")) as { + inputs: { market: { feeBps: number; yesBps: number; reserveYesStroops: string } }; + }; + assert.equal(trace.inputs.market.feeBps, 321); + assert.equal(trace.inputs.market.yesBps, 5000); + assert.equal(trace.inputs.market.reserveYesStroops, "1000000000000000"); +}); diff --git a/oracledesk-stellar/agents/bin/trader.ts b/oracledesk-stellar/agents/bin/trader.ts index 6e40bab..9810063 100644 --- a/oracledesk-stellar/agents/bin/trader.ts +++ b/oracledesk-stellar/agents/bin/trader.ts @@ -16,16 +16,47 @@ import "dotenv/config"; import { pathToFileURL } from "node:url"; import { buildTrace, LocalTraceStore } from "../core/trace.js"; -import { BPS } from "../core/fpmm.js"; +import { BPS, priceYesBps } from "../core/fpmm.js"; import { decideTrade, type MarketState } from "../core/strategy.js"; import { loadDeployments, StellarAdapter } from "../stellar/adapter.js"; -/** Placeholder in place of a real market read + reference-price lookup — - * see docs/wave-issues for wiring get_market/get_price and a - * ReferencePriceSource as open issues. */ -async function fetchMarketAndFairValue(): Promise<{ market: MarketState; fairValueBps: number }> { +/** + * Reads the market to trade and the fair value to compare it against. + * + * In live mode the market is a real chain read (`get_market` plus + * `current_fee_bps`) for `MARKET_ID`. The effective fee — not the flat + * `Market.fee_bps` snapshot, which ignores dynamic fee schedules — is fed + * into the strategy's market state so every off-chain quote and impact + * estimate uses the number the contract will actually charge. + * + * The fair-value estimate stays a placeholder: wiring a real + * ReferencePriceSource (and the question lookup it needs) is an open issue — + * see docs/wave-issues. + */ +async function fetchMarketAndFairValue( + adapter: StellarAdapter, + env: NodeJS.ProcessEnv, + now: () => number, +): Promise<{ market: MarketState; fairValueBps: number }> { + if (adapter.mode === "live") { + const marketId = BigInt(env.MARKET_ID ?? "0"); + const m = await adapter.getMarket({ market_id: marketId }); + const feeBps = await adapter.currentFeeBps({ market_id: marketId }); + return { + market: { + marketId: Number(marketId), + question: "live market", + yesBps: Number(priceYesBps(m.reserve_yes, m.reserve_no)), + closeTime: Number(m.close_time), + reserveYesStroops: m.reserve_yes, + reserveNoStroops: m.reserve_no, + feeBps, + }, + fairValueBps: 6000, + }; + } return { - market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: Math.floor(Date.now() / 1000) + 3600 }, + market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: now() + 3600 }, fairValueBps: 6000, }; } @@ -42,7 +73,7 @@ export async function main( const traceStore = new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces"); console.log(`[trader] mode=${adapter.mode}`); - const { market, fairValueBps } = await fetchMarketAndFairValue(); + const { market, fairValueBps } = await fetchMarketAndFairValue(adapter, env, now); const decision = decideTrade(market, fairValueBps, { minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500), tradeSizeStroops: BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"), diff --git a/oracledesk-stellar/agents/core/fpmm.test.ts b/oracledesk-stellar/agents/core/fpmm.test.ts index e4422d9..c231a18 100644 --- a/oracledesk-stellar/agents/core/fpmm.test.ts +++ b/oracledesk-stellar/agents/core/fpmm.test.ts @@ -1,18 +1,31 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { BPS, buyWithinLimit, calcBuy, calcSell, + catCalcBuy, + catCalcSell, + catPrices, + catSeed, + catSettleValue, collateralOutForShares, + effectiveFeeBps, + MathError, + MAX_MARKET_OUTCOMES, + MAX_RAMP_SECS, + MAX_SKEW_MULT_BPS, priceAfter, priceAfterBuy, priceAfterSell, priceYesBps, + scalarPayout, seedReserves, sellWithinLimit, settleValue, + type FeeSchedule, } from "./fpmm.js"; // Same worked examples as math.rs's unit tests and model/fpmm_model.py's @@ -148,3 +161,255 @@ test("buyWithinLimit and sellWithinLimit mirror the on-chain boundaries", () => assert.equal(sellWithinLimit(0n, 1n, 0n), true); assert.equal(buyWithinLimit(1n, 1n, 0n), false); }); + +// Same small LCG as math.rs / fpmm_model.py, so the three suites explore a +// comparable input space without a property-testing dependency. +function lcg(seed: bigint): () => bigint { + let x = seed; + const mask = (1n << 64n) - 1n; + return () => { + x = (x * 6364136223846793005n + 1442695040888963407n) & mask; + return x >> 33n; + }; +} + +test("catCalcBuy and catCalcSell match the reference 3-outcome example", () => { + // Same worked examples as math_categorical.rs and model/fpmm_model.py: + // a 1000/1000/1000 pool, buying/selling 100 of outcome 0 with no fee. + const q = catCalcBuy([1000n, 1000n, 1000n], 0, 100n, 0n); + assert.equal(q.sharesOut, 272n); + assert.deepEqual(q.newReserves, [828n, 1100n, 1100n]); + const s = catCalcSell([1000n, 1000n, 1000n], 0, 100n, 0n); + assert.equal(s.sharesIn, 336n); + assert.deepEqual(s.newReserves, [1236n, 900n, 900n]); + + // An n = 2 categorical pool is the binary FPMM: same fee/net/reserves and + // a bought outcome within one unit of rounding. + const bin = calcBuy(1_000_000n, 3_000_000n, 100_000_000n, 100n); + const cat = catCalcBuy([1_000_000n, 3_000_000n], 0, 100_000_000n, 100n); + assert.equal(cat.fee, bin.fee); + assert.equal(cat.net, bin.net); + assert.equal(cat.newReserves[1], bin.newReserveOther); + const drift = cat.sharesOut - bin.sharesOut; + assert.ok(drift <= 1n && drift >= -1n, `drift=${drift}`); +}); + +test("catCalcBuy keeps k non-decreasing and a round trip never profits", () => { + const next = lcg(0x9e3779b97f4a7c15n); + for (let i = 0; i < 2000; i++) { + const n = 2 + Number(next() % 7n); + const reserves = Array.from({ length: n }, () => 100n + (next() % 10_000n)); + const j = Number(next() % BigInt(n)); + const amt = 100n + (next() % 100n); + const fee = next() % 300n; + + const k0 = reserves.reduce((a, b) => a * b, 1n); + const q = catCalcBuy(reserves, j, amt, fee); + assert.ok(q.newReserves.reduce((a, b) => a * b, 1n) >= k0, "buy decreased k"); + for (let k = 0; k < n; k++) { + if (k !== j) assert.equal(q.newReserves[k], reserves[k] + q.net); + } + try { + const s = catCalcSell(q.newReserves, j, amt, fee); + assert.ok(s.sharesIn >= q.sharesOut, "free money"); + } catch { + // insufficient liquidity to sell back out is fine, not what's tested here + } + } +}); + +test("catPrices sum to within n of BPS and are flat for equal reserves", () => { + const next = lcg(0xb1a5e5b1a5b1a5b1n); + for (let i = 0; i < 2000; i++) { + const n = 2 + Number(next() % 7n); + const reserves = Array.from({ length: n }, () => 1_000n + (next() % 1_000_000_000_000n)); + const sum = catPrices(reserves).reduce((a, b) => a + b, 0n); + assert.ok(sum <= BPS, `prices sum above BPS: n=${n} sum=${sum}`); + assert.ok(sum + BigInt(n) >= BPS, `prices sum below BPS - n: n=${n} sum=${sum}`); + } + for (let n = 2; n <= MAX_MARKET_OUTCOMES; n++) { + const prices = catPrices(Array.from({ length: n }, () => 7_777n)); + assert.deepEqual(prices, Array.from({ length: n }, () => BigInt(Math.floor(10_000 / n)))); + } +}); + +test("catSeed opens at the requested probabilities and validates inputs", () => { + for (const probs of [ + [5000, 5000], + [6800, 3200], + [5000, 3000, 2000], + [9000, 500, 400, 100], + ]) { + const s = catSeed(1_000_000_000n, probs); + const prices = catPrices(s.reserves); + for (let i = 0; i < probs.length; i++) { + const drift = prices[i] - BigInt(probs[i]); + assert.ok(drift <= 2n && drift >= -2n, `price drift ${drift} bps at i=${i}`); + assert.equal(s.reserves[i] + s.creatorShares[i], 1_000_000_000n); + } + const minIndex = probs.indexOf(Math.min(...probs)); + assert.equal(s.creatorShares[minIndex], 0n); + } + // 8 equal outcomes: prices sum to exactly BPS, creator holds nothing. + const flat = catSeed(1_000_000_000n, Array(8).fill(1250)); + assert.deepEqual(catPrices(flat.reserves), Array(8).fill(1250n)); + assert.ok(flat.creatorShares.every((c) => c === 0n)); + + // Validation: bad sum, below MIN_PROB_BPS, n out of range, seed 0. + for (const [seed, probs] of [ + [1_000n, [6000, 3000]], + [1_000n, [9950, 50]], + [1_000n, [10_000]], + [1_000n, Array(9).fill(1250)], + [0n, [5000, 5000]], + ] as [bigint, number[]][]) { + assert.throws( + () => catSeed(seed, probs), + MathError, + `catSeed accepted ${probs} seed=${seed}`, + ); + } +}); + +test("catSettleValue never overpays and handles void", () => { + const next = lcg(0xdeadbeefdeadbeefn); + for (let i = 0; i < 1000; i++) { + const n = 2 + Number(next() % 7n); + const sets = 1_000_000n; + // Three holders share each outcome's `sets` shares with the pool. + const held: bigint[][] = Array.from({ length: n }, () => { + let rest = sets; + return [0, 1, 2].map((h) => { + const take = h === 2 ? rest : next() % (rest + 1n); + rest -= take; + return take; + }); + }); + const w = Number(next() % BigInt(n)); + let resolvedTotal = 0n; + let voidTotal = 0n; + for (let h = 0; h < 3; h++) { + const balances = held.map((col) => col[h]); + resolvedTotal += catSettleValue(w, balances); + voidTotal += catSettleValue(null, balances); + } + assert.ok(resolvedTotal <= sets); + assert.ok(voidTotal <= sets, `void overpaid: ${voidTotal} > ${sets}`); + } + assert.equal(catSettleValue(1, [3n, 7n, 11n]), 7n); + assert.equal(catSettleValue(null, [3n, 7n, 11n]), 7n); // 21 / 3 + assert.equal(catSettleValue(null, Array(7).fill(1n)), 1n); +}); + +test("scalarPayout matches the binary legs at the extremes and never overpays", () => { + assert.equal(scalarPayout(BPS, 5n, 7n), settleValue("yes", 5n, 7n)); + assert.equal(scalarPayout(0n, 5n, 7n), settleValue("no", 5n, 7n)); + assert.equal(scalarPayout(5_000n, 1_000n, 1_000n), 1_000n); + assert.equal(scalarPayout(3_333n, 1_000n, 1_000n), 999n); + + const next = lcg(0x5ca1ab1e5ca1ab1en); + for (let i = 0; i < 1000; i++) { + const sets = 1n + (next() % 1_000_000n); + const payout = next() % (BPS + 1n); + // Three holders split `sets` YES and `sets` NO shares exactly. + let yesLeft = sets; + let noLeft = sets; + let total = 0n; + for (let h = 0; h < 3; h++) { + const takeY = h === 2 ? yesLeft : next() % (yesLeft + 1n); + const takeN = h === 2 ? noLeft : next() % (noLeft + 1n); + yesLeft -= takeY; + noLeft -= takeN; + total += scalarPayout(payout, takeY, takeN); + } + assert.ok(total <= sets, `scalar overpaid: ${total} > ${sets} at p=${payout}`); + } +}); + +test("effectiveFeeBps matches the shared cross-language fixtures", () => { + // Same table as math.rs's effective_fee_matches_fixtures and + // model/fpmm_model.py's _check_effective_fee_known_values: one row per + // fixture, nine numbers — [base_bps, max_bps, ramp_secs, skew_mult_bps, + // now, close, r_yes, r_no, expected_fee_bps]. + const rows = JSON.parse( + readFileSync(new URL("../../model/fee_fixtures.json", import.meta.url), "utf8"), + ) as number[][]; + assert.ok(rows.length > 0, "fee_fixtures.json has no rows"); + for (const row of rows) { + assert.equal(row.length, 9); + const schedule: FeeSchedule = { + baseBps: BigInt(row[0]), + maxBps: BigInt(row[1]), + rampSecs: BigInt(row[2]), + skewMultBps: BigInt(row[3]), + }; + const fee = effectiveFeeBps(schedule, BigInt(row[4]), BigInt(row[5]), BigInt(row[6]), BigInt(row[7])); + assert.equal(fee, BigInt(row[8]), `fixture ${JSON.stringify(row)}`); + } +}); + +test("effectiveFeeBps stays within bounds and ramps monotonically", () => { + const next = lcg(0xfee10beefee10been); + for (let i = 0; i < 2000; i++) { + const base = next() % 1_000n; + const maxBps = base + (next() % (1_000n - base + 1n)); + const schedule: FeeSchedule = { + baseBps: base, + maxBps, + rampSecs: next() % (MAX_RAMP_SECS + 1n), + skewMultBps: next() % (MAX_SKEW_MULT_BPS + 1n), + }; + const close = 1_000n + (next() % 2_000_000_000n); + const now = next() % (close + 10_000n); + const fee = effectiveFeeBps(schedule, now, close, 1n + (next() % 10n ** 15n), 1n + (next() % 10n ** 15n)); + assert.ok(fee >= base && fee <= maxBps, `fee=${fee} base=${base} max=${maxBps}`); + } + + // Ramp monotonic in time, full ramp at/past close. + const sched: FeeSchedule = { baseBps: 100n, maxBps: 800n, rampSecs: 10_000n, skewMultBps: 0n }; + let prev = -1n; + for (let t = 2_000_000n - 20_000n; t <= 2_000_000n + 5_000n; t += 137n) { + const fee = effectiveFeeBps(sched, t, 2_000_000n, 1_000n, 1_000n); + assert.ok(fee >= prev); + prev = fee; + } + assert.equal(prev, 800n); + + // Zero ramp means "no ramp", even at close. + const flat: FeeSchedule = { baseBps: 100n, maxBps: 800n, rampSecs: 0n, skewMultBps: 0n }; + assert.equal(effectiveFeeBps(flat, 0n, 2_000_000n, 1_000n, 1_000n), 100n); + assert.equal(effectiveFeeBps(flat, 2_000_000n, 2_000_000n, 1_000n, 1_000n), 100n); + + // Skew is symmetric around 50/50 for representable ratios. + for (const [a, b] of [ + [1000n, 3000n], + [900n, 1100n], + [5000n, 5000n], + [1n, 4n], + [7n, 3n], + [1n, 1n], + ]) { + const skewSched: FeeSchedule = { baseBps: 100n, maxBps: 1_000n, rampSecs: 0n, skewMultBps: 500n }; + assert.equal( + effectiveFeeBps(skewSched, 0n, 1n, a, b), + effectiveFeeBps(skewSched, 0n, 1n, b, a), + `skew asymmetry for ${a}/${b}`, + ); + } + + // The pure function only requires base <= max < BPS (the tighter + // MAX_FEE_BPS cap is the contract's validate_schedule), so (1000, 1000) + // is valid here. + assert.equal( + effectiveFeeBps({ baseBps: 1_000n, maxBps: 1_000n, rampSecs: 0n, skewMultBps: 0n }, 0n, 1n, 1_000n, 1_000n), + 1_000n, + ); + assert.throws( + () => effectiveFeeBps({ baseBps: 500n, maxBps: 100n, rampSecs: 0n, skewMultBps: 0n }, 0n, 1n, 1_000n, 1_000n), + MathError, + ); + assert.throws( + () => effectiveFeeBps({ baseBps: 0n, maxBps: BPS, rampSecs: 0n, skewMultBps: 0n }, 0n, 1n, 1_000n, 1_000n), + MathError, + ); +}); diff --git a/oracledesk-stellar/agents/core/fpmm.ts b/oracledesk-stellar/agents/core/fpmm.ts index ed027f7..1bcb34c 100644 --- a/oracledesk-stellar/agents/core/fpmm.ts +++ b/oracledesk-stellar/agents/core/fpmm.ts @@ -230,3 +230,240 @@ export function sellWithinLimit(collateralOut: bigint, sharesIn: bigint, limitBp if (collateralOut < 0n || sharesIn <= 0n) throw new MathError("invalid input"); return collateralOut * BPS >= sharesIn * limitBps; } + +// --------------------------------------------------------------------------- +// Categorical (N-outcome) FPMM — mirrors math_categorical.rs. The binary +// invariant k = rYes * rNo generalises to the product of all reserves; the +// iterative ceil accumulation keeps k non-decreasing with at most n-1 units +// of pool-favouring drift per trade (see the rounding proof in +// math_categorical.rs::calc_buy). +// --------------------------------------------------------------------------- + +export const MAX_MARKET_OUTCOMES = 8; +export const PRICE_SCALE = 10n ** 18n; // same reciprocal scale as math_categorical.rs + +function catN(reserves: bigint[]): number { + const n = reserves.length; + if (n < 2 || n > MAX_MARKET_OUTCOMES) throw new MathError("invalid input"); + for (const r of reserves) if (r <= 0n) throw new MathError("invalid input"); + return n; +} + +function catIndex(n: number, outcomeIndex: number): number { + if (outcomeIndex < 0 || outcomeIndex >= n) throw new MathError("invalid input"); + return outcomeIndex; +} + +export interface CatBuyQuote { + sharesOut: bigint; + fee: bigint; + net: bigint; + newReserves: bigint[]; +} + +/** + * Buy `collateralIn` of outcome `outcomeIndex`: mint `net` sets into every + * other reserve and shrink the bought reserve so the product of all + * reserves holds. Mirrors math_categorical.rs::calc_buy. + */ +export function catCalcBuy( + reserves: bigint[], + outcomeIndex: number, + collateralIn: bigint, + feeBps: bigint, +): CatBuyQuote { + const n = catN(reserves); + const j = catIndex(n, outcomeIndex); + if (collateralIn <= 0n) throw new MathError("invalid input"); + const fee = mulDivCeil(collateralIn, feeBps, BPS); + const net = collateralIn - fee; + if (net <= 0n) throw new MathError("invalid input"); + const newReserves = [...reserves]; + let acc = reserves[j]; + for (let i = 0; i < n; i++) { + if (i === j) continue; + const newOther = reserves[i] + net; + newReserves[i] = newOther; + acc = mulDivCeil(acc, reserves[i], newOther); + } + newReserves[j] = acc; + const sharesOut = reserves[j] + net - acc; + if (sharesOut <= 0n) throw new MathError("invalid input"); + return { sharesOut, fee, net, newReserves }; +} + +export interface CatSellQuote { + sharesIn: bigint; + fee: bigint; + gross: bigint; + newReserves: bigint[]; +} + +/** + * Sell `outcomeIndex` for an exact `collateralOut` (Gnosis-style, so no + * square root on-chain). Mirrors math_categorical.rs::calc_sell. + */ +export function catCalcSell( + reserves: bigint[], + outcomeIndex: number, + collateralOut: bigint, + feeBps: bigint, +): CatSellQuote { + const n = catN(reserves); + const j = catIndex(n, outcomeIndex); + if (collateralOut <= 0n || feeBps >= BPS) throw new MathError("invalid input"); + const gross = mulDivCeil(collateralOut, BPS, BPS - feeBps); + const fee = gross - collateralOut; + const newReserves = [...reserves]; + let acc = reserves[j]; + for (let i = 0; i < n; i++) { + if (i === j) continue; + if (reserves[i] <= gross) throw new MathError("insufficient liquidity"); + const newOther = reserves[i] - gross; + newReserves[i] = newOther; + acc = mulDivCeil(acc, reserves[i], newOther); + } + newReserves[j] = acc; + const sharesIn = acc + gross - reserves[j]; + if (sharesIn <= 0n) throw new MathError("invalid input"); + return { sharesIn, fee, gross, newReserves }; +} + +/** + * Spot price of every outcome in bps, `p_j = (1/r_j) / Σ(1/r_i)` evaluated + * on integer reciprocals. Each price floors, so the n prices sum to at most + * BPS and at least BPS - n (display-only; never trade math). Mirrors + * math_categorical.rs::prices_bps. + */ +export function catPrices(reserves: bigint[]): bigint[] { + const n = catN(reserves); + const recs = reserves.slice(0, n).map((r) => PRICE_SCALE / r); + let total = 0n; + for (const rec of recs) total += rec; + if (total <= 0n) throw new MathError("insufficient liquidity"); + return recs.map((rec) => mulDivFloor(BPS, rec, total)); +} + +export interface CatSeed { + reserves: bigint[]; + creatorShares: bigint[]; +} + +/** + * Open a pool at the requested probabilities: each reserve is + * `seed * pMin / p_i` (floored), the creator keeps `seed - r_i` of every + * outcome. `probsBps` must have length 2..=MAX_MARKET_OUTCOMES, every entry + * >= MIN_PROB_BPS, and the entries must sum to exactly BPS. Mirrors + * math_categorical.rs::seed_reserves. + */ +export function catSeed(seed: bigint, probsBps: number[]): CatSeed { + const n = probsBps.length; + if (n < 2 || n > MAX_MARKET_OUTCOMES || seed <= 0n) throw new MathError("invalid input"); + let sum = 0; + for (const p of probsBps) { + if (BigInt(p) < MIN_PROB_BPS) throw new MathError("invalid input"); + sum += p; + } + if (sum !== 10_000) throw new MathError("invalid input"); + const pMin = BigInt(Math.min(...probsBps)); + const reserves: bigint[] = []; + const creatorShares: bigint[] = []; + for (const p of probsBps) { + const r = mulDivFloor(seed, pMin, BigInt(p)); + if (r <= 0n) throw new MathError("invalid input"); + reserves.push(r); + creatorShares.push(seed - r); + } + return { reserves, creatorShares }; +} + +/** + * Collateral value of one holder's per-outcome balances once final. + * `resolved = w` pays the winning balance; `resolved = null` (void) pays + * `floor(Σ balances / n)`. Every complete set is worth exactly 1 and every + * rounding floors, so the total paid across holders never exceeds + * setsMinted. Mirrors math_categorical.rs::settle_value. + */ +export function catSettleValue(resolved: number | null, balances: bigint[]): bigint { + const n = balances.length; + if (n < 2 || n > MAX_MARKET_OUTCOMES) throw new MathError("invalid input"); + if (resolved === null) { + let sum = 0n; + for (const b of balances) { + if (b < 0n) throw new MathError("invalid input"); + sum += b; + } + return sum / BigInt(n); + } + catIndex(n, resolved); + return balances[resolved]; +} + +// --------------------------------------------------------------------------- +// Scalar (range) market settlement — mirrors math.rs::scalarPayout, which +// backs the Rust `Settlement::Scalar(p)` variant. `scalarPayout(BPS, ...)` +// equals YES and `(0, ...)` equals NO. +// --------------------------------------------------------------------------- + +/** + * Collateral value of a (yes, no) share pair when the resolved value maps + * to `payoutLongBps` LONG per complete set. Both legs floor independently, + * so no holder ever beats their exact rational share. + */ +export function scalarPayout(payoutLongBps: bigint, yes: bigint, no: bigint): bigint { + return (yes * payoutLongBps) / BPS + (no * (BPS - payoutLongBps)) / BPS; +} + +// --------------------------------------------------------------------------- +// Dynamic fee schedule — mirrors math.rs::effectiveFeeBps. Deterministic +// integer math shared with the contract and model/fpmm_model.py via +// model/fee_fixtures.json; every floor rounds toward the trader (a lower +// fee never harms the pool, whose protection is calc* rounding). +// --------------------------------------------------------------------------- + +export const MAX_RAMP_SECS = 30n * 86_400n; +export const MAX_SKEW_MULT_BPS = 1_000n; + +export interface FeeSchedule { + baseBps: bigint; + maxBps: bigint; + rampSecs: bigint; + skewMultBps: bigint; +} + +/** + * Fee in bps for a trade at `now` with the given pre-trade reserves: + * `min(max, base + timeRamp + skew)`. The ramp adds up to `max - base` + * linearly over the last `rampSecs` before close (0 disables it, and any + * timestamp at or past close clamps to the full ramp); the skew term adds + * `skewMultBps` per 1000 bps of distance from a 50/50 price. Guaranteed + * `base <= result <= max`, and `max < BPS` is validated so `calcSell` is + * always well-defined. + */ +export function effectiveFeeBps( + schedule: FeeSchedule, + now: bigint, + closeTime: bigint, + rYes: bigint, + rNo: bigint, +): bigint { + if (schedule.baseBps > schedule.maxBps || schedule.maxBps >= BPS) { + throw new MathError("invalid input"); + } + let t = 0n; + if (schedule.rampSecs !== 0n) { + const intoRamp = + now >= closeTime + ? schedule.rampSecs + : closeTime - now >= schedule.rampSecs + ? 0n + : schedule.rampSecs - (closeTime - now); + t = mulDivFloor(intoRamp, schedule.maxBps - schedule.baseBps, schedule.rampSecs); + } + const p = priceYesBps(rYes, rNo); + const midpoint = BPS / 2n; + const d = p >= midpoint ? p - midpoint : midpoint - p; + const skew = mulDivFloor(d, schedule.skewMultBps, 1_000n); + const total = schedule.baseBps + t + skew; + return total > schedule.maxBps ? schedule.maxBps : total; +} diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index 54a3544..23875c4 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -31,6 +31,15 @@ export interface Deployments { }; } +/** Subset of market-core's `Market` the agents consume. i128 maps to bigint + * and u64 to bigint per the generated bindings (`u32` -> number). */ +export interface MarketView { + close_time: bigint; + fee_bps: number; + reserve_yes: bigint; + reserve_no: bigint; +} + export function loadDeployments(path = join(REPO_ROOT, "deployments", "testnet.json")): Deployments { return JSON.parse(readFileSync(path, "utf8")) as Deployments; } @@ -212,6 +221,30 @@ export class StellarAdapter { return result as bigint; } + /** + * Reads a market's full state via market-core's `get_market` view + * (read-only — simulates, never signs or submits). Lets the trader seed + * its market state (reserves, close time) from the chain in live mode. + */ + async getMarket(args: { market_id: bigint }): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const { result } = await client.get_market(args); + return result as MarketView; + } + + /** + * Reads market-core's `current_fee_bps` view: the fee a trade would pay + * right now, including any dynamic `FeeSchedule`. That schedule is + * deliberately invisible in `Market.fee_bps` (the flat snapshot legacy + * markets charge), so quoting off the snapshot would understate the fee + * near close. Read-only, simulates only. + */ + async currentFeeBps(args: { market_id: bigint }): Promise { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const { result } = await client.current_fee_bps(args); + return result as number; + } + async publishTrace(args: { agent: string; market_id: bigint; diff --git a/oracledesk-stellar/contracts/market-core/src/lib.rs b/oracledesk-stellar/contracts/market-core/src/lib.rs index 57e537e..4d7962d 100644 --- a/oracledesk-stellar/contracts/market-core/src/lib.rs +++ b/oracledesk-stellar/contracts/market-core/src/lib.rs @@ -1,11 +1,15 @@ #![no_std] +#[cfg(test)] +extern crate std; + mod math; +mod math_categorical; use math::{MathError, Settlement, BPS, MAX_SETS}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, token, - Address, BytesN, Env, String, + Address, BytesN, Env, String, Vec, }; // --------------------------------------------------------------------------- @@ -22,6 +26,9 @@ const MAX_FEE_BPS: u32 = 1_000; /// Minimum seed liquidity: 100 USDC (7 decimals). Stops spam markets. const MIN_SEED: i128 = 100 * 10_000_000; +/// Outcome-count cap for categorical markets (mirrors the math module). +const MAX_N: usize = math_categorical::MAX_MARKET_OUTCOMES as usize; + // --------------------------------------------------------------------------- // Types shared with the frontend/backend // --------------------------------------------------------------------------- @@ -56,6 +63,22 @@ pub enum MarketStatus { Resolved(Outcome), /// Invalid market. Every share redeems at 0.5. Void, + /// Scalar (range) market, final: LONG shares pay `payout_long_bps / + /// 10_000` and SHORT shares the complement. Appended last so stored + /// `Resolved`/`Void` values keep their encoding; readers compiled + /// against older bindings fail to decode this one (see the release note). + ResolvedScalar(u32), +} + +/// Display metadata of a scalar market: the `[lower, upper]` range of the +/// committed resolution spec, in the feed's raw units. Stored under +/// `DataKey::Scalar(u64)` at creation; the resolver's revealed spec is the +/// authoritative copy, this entry only tells UIs what to display. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ScalarMeta { + pub lower: i128, + pub upper: i128, } #[contracttype] @@ -76,7 +99,9 @@ pub struct Market { pub category: Category, /// Unix seconds (ledger timestamp). Trading stops at this time. pub close_time: u64, - /// Snapshot of the fee at creation so later config changes never move it. + /// Snapshot of the flat fee at creation so later config changes never + /// move it. Charged as-is by categorical markets and by binary markets + /// without a `FeeSchedule` side entry (legacy / flat-fee markets). pub fee_bps: u32, pub status: MarketStatus, pub reserve_yes: i128, @@ -96,6 +121,63 @@ pub struct Position { pub no: i128, } +/// Which pool a market id refers to. Absent for markets created before +/// categorical markets existed, hence `market_kind` reports `Binary` for +/// a stored `Market(u64)` and a `Kind` entry is only written for +/// categorical ids (which store a `CatMarket` instead of a `Market`). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MarketKind { + Binary, + /// Outcome count, 2..=MAX_MARKET_OUTCOMES. + Categorical(u32), +} + +/// Lifecycle of a categorical market: same three states as `MarketStatus` +/// with the winning index instead of a two-way outcome. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CatStatus { + Open, + /// Final. Winning shares redeem 1:1. + Resolved(u32), + /// Invalid market. Every share redeems at 1/n. + Void, +} + +/// Categorical market state. Stored under `DataKey::CatMarket(u64)`, sharing +/// the id space with binary markets (`DataKey::NextId`), so `get_market` on +/// a categorical id is `MarketNotFound` and callers must read `market_kind`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatMarket { + pub creator: Address, + /// Same commitment scheme as the binary `Market` (see there). + pub question_hash: BytesN<32>, + pub resolution_hash: BytesN<32>, + pub meta_uri: String, + pub category: Category, + pub close_time: u64, + pub fee_bps: u32, + pub status: CatStatus, + /// One reserve per outcome; `len()` is the outcome count n. + pub reserves: Vec, + /// Complete sets in existence == collateral locked for this market. Each + /// outcome's shares (reserve + all holder balances) sum to this. + pub sets_minted: i128, + pub fees_accrued: i128, + /// Creator has withdrawn the pool remainder after finality. + pub lp_claimed: bool, +} + +/// Per-holder categorical position: one balance per outcome, same order as +/// `CatMarket::reserves`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatPosition { + pub balances: Vec, +} + /// Fee destination and split, snapshotted per market at creation as a side /// entry (`DataKey::FeeTerms`) so `Market`'s XDR shape never changes. /// @@ -114,6 +196,35 @@ pub struct FeeTerms { pub protocol_share_bps: u32, } +/// Dynamic fee schedule, snapshotted per binary market at creation as a +/// side entry (`DataKey::FeeSchedule`) so `Market`'s XDR shape never +/// changes. The effective rate of a trade is +/// `math::effective_fee_bps(schedule, now, close_time, r_yes, r_no)`: +/// a base rate that ramps up towards `max_bps` as `close_time` approaches +/// and grows with the pool's distance from 50/50, protecting the single LP +/// from informed flow near expiry and from one-sided pools. +/// +/// Markets created before this existed (and categorical markets, which +/// still charge their flat `Market::fee_bps`) have no `FeeSchedule` entry +/// and keep a flat fee. The schedule is immutable after creation; only +/// `set_default_fee_schedule` (the default for *new* markets) can change. +/// Field order is part of the XDR — keep `base_bps`, `max_bps`, +/// `ramp_secs`, `skew_mult_bps` stable. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FeeSchedule { + /// Fee far from close with the pool at 50/50. + pub base_bps: u32, + /// Hard ceiling for this market, `<= MAX_FEE_BPS`. + pub max_bps: u32, + /// The time ramp starts this many seconds before `close_time` + /// (0 = no ramp, `<= math::MAX_RAMP_SECS`). + pub ramp_secs: u64, + /// Extra bps per 1_000 bps of distance from a 5_000 price + /// (`<= math::MAX_SKEW_MULT_BPS`). + pub skew_mult_bps: u32, +} + /// Per-market circuit breakers, snapshotted at creation from /// `DataKey::DefaultBreaker`. 0 disables each check. A market without a /// `Breaker` entry (created before breakers existed) is never checked. @@ -170,6 +281,21 @@ pub enum DataKey { Breaker(u64), // temporary storage (expires on its own, one entry per market) Band(u64), + // appended (new variants only ever go at the end — the XDR tag is the + // variant position) + /// Only written for categorical markets; absent means binary. + Kind(u64), + CatMarket(u64), + CatPosition(u64, Address), + /// Display metadata of a scalar market, written by + /// `create_scalar_market`; absent for binary and categorical markets. + Scalar(u64), + /// Dynamic fee schedule snapshotted at creation; absent for legacy and + /// categorical markets, which charge the flat `Market::fee_bps`. + FeeSchedule(u64), + /// Instance storage, but appended here anyway: the XDR tag is the + /// variant position, so even instance keys must only ever be appended. + DefaultFeeSchedule, } // --------------------------------------------------------------------------- @@ -208,6 +334,10 @@ pub enum Error { PriceImpactTooHigh = 21, /// Cumulative same-ledger movement would exceed `band_bps`. PriceBandExceeded = 22, + // 23..=27 reserved for an in-flight contributor PR (deadline/limit v2 + // work on `develop`); new codes here start at 28. + /// `outcome_index` is not a valid outcome of this categorical market. + InvalidOutcomeIndex = 28, } impl From for Error { @@ -251,6 +381,55 @@ pub struct Trade { pub reserve_no: i128, } +/// Same trade as `Trade` plus the realised fee rate. New event rather than +/// a new field so the existing `Trade` XDR stays stable for indexers (the +/// `FeesSweptTo` convention); both are emitted on every binary trade. +/// `fee_bps` is the effective dynamic rate applied, already snapshotted +/// into `fee` — indexers can recover it from `fee / collateral` but not +/// exactly, because `fee` carries the ceiling rounding. +#[contractevent] +pub struct TradeV2 { + #[topic] + pub market_id: u64, + #[topic] + pub trader: Address, + pub outcome: Outcome, + pub is_buy: bool, + pub collateral: i128, + pub shares: i128, + pub fee: i128, + pub fee_bps: u32, + pub reserve_yes: i128, + pub reserve_no: i128, +} + +#[contractevent] +pub struct CatMarketCreated { + #[topic] + pub market_id: u64, + pub creator: Address, + pub category: Category, + pub close_time: u64, + pub seed_amount: i128, + /// Requested opening probabilities, one per outcome, summing to BPS. + pub probs_bps: Vec, +} + +#[contractevent] +pub struct CatTrade { + #[topic] + pub market_id: u64, + #[topic] + pub trader: Address, + pub outcome_index: u32, + pub is_buy: bool, + pub collateral: i128, + pub shares: i128, + pub fee: i128, + /// Post-trade reserves, same order as `CatMarket::reserves`. + pub reserves: Vec, +} + #[contractevent] pub struct MarketResolved { #[topic] @@ -259,6 +438,23 @@ pub struct MarketResolved { pub outcome: Option, } +#[contractevent] +pub struct CatMarketResolved { + #[topic] + pub market_id: u64, + /// None means the market was voided. + pub outcome_index: Option, +} + +#[contractevent] +pub struct MarketResolvedScalar { + #[topic] + pub market_id: u64, + /// LONG payout per complete set, 0..=BPS; the SHORT leg pays the + /// complement. `MarketResolved` is never emitted for scalar markets. + pub payout_long_bps: u32, +} + #[contractevent] pub struct Redeemed { #[topic] @@ -397,6 +593,20 @@ impl MarketCore { Ok(()) } + /// Sets the dynamic fee schedule snapshotted into binary markets created + /// after the call. Bounded by `MAX_FEE_BPS` and the ramp/skew caps; + /// existing markets keep their schedule. A creator can pick a stricter + /// schedule through `create_market_with_fees`, never a looser one (its + /// `max_bps` is capped by this default's `max_bps`). + pub fn set_default_fee_schedule(env: Env, schedule: FeeSchedule) -> Result<(), Error> { + Self::require_admin(&env)?; + Self::validate_schedule(&schedule, MAX_FEE_BPS)?; + env.storage() + .instance() + .set(&DataKey::DefaultFeeSchedule, &schedule); + Ok(()) + } + pub fn set_resolver(env: Env, resolver: Address) -> Result<(), Error> { Self::require_admin(&env)?; env.storage().instance().set(&DataKey::Resolver, &resolver); @@ -459,6 +669,9 @@ impl MarketCore { /// Creates a market and seeds the pool. `creator` becomes the sole LP. /// `initial_yes_bps` in [100, 9900] sets the opening YES probability; /// the creator receives the leftover shares of the likelier outcome. + /// When a default dynamic fee schedule is set it is snapshotted for + /// this market (see `set_default_fee_schedule`); otherwise the market + /// charges the flat `DefaultFeeBps`. #[allow(clippy::too_many_arguments)] pub fn create_market( env: Env, @@ -470,6 +683,66 @@ impl MarketCore { close_time: u64, seed_amount: i128, initial_yes_bps: u32, + ) -> Result { + Self::do_create_market( + env, + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + None, + ) + } + + /// `create_market` with an explicit per-market `FeeSchedule`. Validated + /// against `MAX_FEE_BPS` and the current default schedule's `max_bps` + /// (creators can undercut protocol policy, never exceed it), then + /// snapshotted for this market's lifetime. + #[allow(clippy::too_many_arguments)] + pub fn create_market_with_fees( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + schedule: FeeSchedule, + ) -> Result { + Self::do_create_market( + env, + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + Some(schedule), + ) + } + + /// Shared implementation of `create_market` / `create_market_with_fees`. + /// `schedule = None` snapshots `DataKey::DefaultFeeSchedule` when set. + #[allow(clippy::too_many_arguments)] + fn do_create_market( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + schedule: Option, ) -> Result { Self::assert_not_paused(&env)?; creator.require_auth(); @@ -483,6 +756,16 @@ impl MarketCore { if !(math::MIN_PROB_BPS..=math::MAX_PROB_BPS).contains(&initial_yes_bps) { return Err(Error::InvalidProbability); } + // validated before the id is allocated so a rejected schedule + // never burns a market id + if let Some(sch) = &schedule { + let cap = env + .storage() + .instance() + .get::<_, FeeSchedule>(&DataKey::DefaultFeeSchedule) + .map_or(MAX_FEE_BPS, |d| d.max_bps); + Self::validate_schedule(sch, cap)?; + } let seed = math::seed_reserves(seed_amount, initial_yes_bps)?; let s = env.storage().instance(); @@ -496,6 +779,7 @@ impl MarketCore { .ok_or(Error::NotInitialized)?; let treasury: Address = s.get(&DataKey::Treasury).ok_or(Error::NotInitialized)?; let breaker: Option = s.get(&DataKey::DefaultBreaker); + let default_schedule: Option = s.get(&DataKey::DefaultFeeSchedule); let market = Market { creator: creator.clone(), @@ -524,6 +808,9 @@ impl MarketCore { if let Some(b) = breaker { Self::save_breaker(&env, id, &b); } + if let Some(sch) = schedule.or(default_schedule) { + Self::save_fee_schedule(&env, id, &sch); + } if seed.creator_yes > 0 || seed.creator_no > 0 { Self::save_position( &env, @@ -550,6 +837,164 @@ impl MarketCore { Ok(id) } + /// Creates a scalar (range) market: the binary pool with LONG = YES and + /// SHORT = NO, whose final status carries a fractional payout + /// (`MarketStatus::ResolvedScalar`) instead of a side. Everything else + /// matches `create_market`; `[lower, upper]` is display metadata stored + /// under `DataKey::Scalar` (the authoritative range is inside the + /// `ResolutionSpec` committed through `resolution_hash`). + #[allow(clippy::too_many_arguments)] + pub fn create_scalar_market( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + initial_yes_bps: u32, + lower: i128, + upper: i128, + ) -> Result { + if lower >= upper { + return Err(Error::InvalidAmount); + } + let id = Self::create_market( + env.clone(), + creator, + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + seed_amount, + initial_yes_bps, + )?; + let key = DataKey::Scalar(id); + env.storage() + .persistent() + .set(&key, &ScalarMeta { lower, upper }); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(id) + } + + /// Creates a categorical market with 2..=8 outcomes and seeds the pool. + /// `creator` becomes the sole LP and receives the leftover shares of the + /// likelier outcomes. `probs_bps` must have one entry per outcome with + /// every probability in `math::MIN_PROB_BPS..=math::MAX_PROB_BPS` and the + /// entries summing to exactly BPS. + /// + /// Collateral, id space, pause switch, fee snapshot and `resolution_hash` + /// commitment are shared with `create_market`; the pool is the same FPMM + /// with the reserve-product invariant generalised to n outcomes (see + /// `math_categorical`). Per-market circuit breakers are price-based and + /// binary-only, so categorical markets are not snapshotted with them — + /// nor with a `FeeSchedule`: they always charge their flat `fee_bps`. + #[allow(clippy::too_many_arguments)] + pub fn create_categorical_market( + env: Env, + creator: Address, + question_hash: BytesN<32>, + resolution_hash: BytesN<32>, + meta_uri: String, + category: Category, + close_time: u64, + seed_amount: i128, + probs_bps: Vec, + ) -> Result { + Self::assert_not_paused(&env)?; + creator.require_auth(); + + if close_time <= env.ledger().timestamp() { + return Err(Error::InvalidCloseTime); + } + if !(MIN_SEED..=MAX_SETS).contains(&seed_amount) { + return Err(Error::InvalidAmount); + } + let n = probs_bps.len(); + if !(2..=math_categorical::MAX_MARKET_OUTCOMES).contains(&n) { + return Err(Error::InvalidAmount); + } + let mut prob_sum: u32 = 0; + for p in probs_bps.iter() { + if !(math::MIN_PROB_BPS..=math::MAX_PROB_BPS).contains(&p) { + return Err(Error::InvalidProbability); + } + prob_sum = prob_sum.checked_add(p).ok_or(Error::Overflow)?; + } + if prob_sum != BPS as u32 { + return Err(Error::InvalidProbability); + } + let mut probs = [0u32; MAX_N]; + for (i, p) in probs_bps.iter().enumerate() { + probs[i] = p; + } + let seed = math_categorical::seed_reserves(seed_amount, &probs[..n as usize])?; + + let s = env.storage().instance(); + let id: u64 = s.get(&DataKey::NextId).ok_or(Error::NotInitialized)?; + s.set(&DataKey::NextId, &(id + 1)); + let fee_bps: u32 = s + .get(&DataKey::DefaultFeeBps) + .ok_or(Error::NotInitialized)?; + let protocol_share_bps: u32 = s + .get(&DataKey::ProtocolFeeShareBps) + .ok_or(Error::NotInitialized)?; + let treasury: Address = s.get(&DataKey::Treasury).ok_or(Error::NotInitialized)?; + + let market = CatMarket { + creator: creator.clone(), + question_hash, + resolution_hash, + meta_uri, + category, + close_time, + fee_bps, + status: CatStatus::Open, + reserves: Vec::from_slice(&env, &seed.reserves[..n as usize]), + sets_minted: seed_amount, + fees_accrued: 0, + lp_claimed: false, + }; + Self::save_kind(&env, id, &MarketKind::Categorical(n)); + Self::save_cat_market(&env, id, &market); + Self::save_fee_terms( + &env, + id, + &FeeTerms { + recipient: treasury, + protocol_share_bps, + }, + ); + let creator_shares: Vec = Vec::from_slice(&env, &seed.creator_shares[..n as usize]); + if creator_shares.iter().any(|b| b > 0) { + Self::save_cat_position( + &env, + id, + &creator, + &CatPosition { + balances: creator_shares, + }, + ); + } + + Self::token(&env)?.transfer(&creator, env.current_contract_address(), &seed_amount); + + CatMarketCreated { + market_id: id, + creator, + category, + close_time, + seed_amount, + probs_bps, + } + .publish(&env); + Ok(id) + } + // ----- trading -------------------------------------------------------- /// Spend `collateral_in` to buy `outcome` shares. Returns shares received. @@ -637,7 +1082,8 @@ impl MarketCore { Self::assert_tradable(&env, &m)?; let (r_bought, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let q = math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?; if q.shares_out < min_shares_out { return Err(Error::SlippageExceeded); } @@ -680,7 +1126,7 @@ impl MarketCore { Trade { market_id, - trader, + trader: trader.clone(), outcome, is_buy: true, collateral: collateral_in, @@ -690,10 +1136,23 @@ impl MarketCore { reserve_no: m.reserve_no, } .publish(&env); - Ok(q.shares_out) - } - - /// Receive exactly `collateral_out` by selling `outcome` shares. Returns + TradeV2 { + market_id, + trader, + outcome, + is_buy: true, + collateral: collateral_in, + shares: q.shares_out, + fee: q.fee, + fee_bps, + reserve_yes: m.reserve_yes, + reserve_no: m.reserve_no, + } + .publish(&env); + Ok(q.shares_out) + } + + /// Receive exactly `collateral_out` by selling `outcome` shares. Returns /// shares sold. Reverts if more than `max_shares_in` would be needed. /// Clients turn "sell N shares" into `collateral_out` off-chain /// (see `math::calc_sell` docs) and add a slippage margin to `max_shares_in`. @@ -771,7 +1230,8 @@ impl MarketCore { Self::assert_tradable(&env, &m)?; let (r_sold, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let q = math::calc_sell(r_sold, r_other, collateral_out, fee_bps)?; if q.shares_in > max_shares_in { return Err(Error::SlippageExceeded); } @@ -817,6 +1277,18 @@ impl MarketCore { Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); Trade { + market_id, + trader: trader.clone(), + outcome, + is_buy: false, + collateral: collateral_out, + shares: q.shares_in, + fee: q.fee, + reserve_yes: m.reserve_yes, + reserve_no: m.reserve_no, + } + .publish(&env); + TradeV2 { market_id, trader, outcome, @@ -824,6 +1296,7 @@ impl MarketCore { collateral: collateral_out, shares: q.shares_in, fee: q.fee, + fee_bps, reserve_yes: m.reserve_yes, reserve_no: m.reserve_no, } @@ -831,6 +1304,138 @@ impl MarketCore { Ok(q.shares_in) } + /// Categorical-market counterpart of `buy`: spend `collateral_in` on + /// `outcome_index` shares. Returns shares received; reverts if fewer than + /// `min_shares_out` would be received. No v2 (deadline/limit) variant + /// yet — see the categorical notes in `docs/architecture.md`. + pub fn buy_cat( + env: Env, + trader: Address, + market_id: u64, + outcome_index: u32, + collateral_in: i128, + min_shares_out: i128, + ) -> Result { + Self::assert_not_paused(&env)?; + trader.require_auth(); + let mut m = Self::load_cat_market(&env, market_id)?; + Self::assert_tradable_cat(&env, &m)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + if outcome_index >= n { + return Err(Error::InvalidOutcomeIndex); + } + let q = math_categorical::calc_buy( + &reserves[..n as usize], + outcome_index, + collateral_in, + m.fee_bps, + )?; + if q.shares_out < min_shares_out { + return Err(Error::SlippageExceeded); + } + let new_sets = m.sets_minted.checked_add(q.net).ok_or(Error::Overflow)?; + if new_sets > MAX_SETS { + return Err(Error::MarketTooLarge); + } + + // effects + m.reserves = Vec::from_slice(&env, &q.new_reserves[..n as usize]); + m.sets_minted = new_sets; + let protocol_fee = Self::accrue_fees(&env, market_id, q.fee)?; + m.fees_accrued = m + .fees_accrued + .checked_add(protocol_fee) + .ok_or(Error::Overflow)?; + let mut pos = Self::load_cat_position(&env, market_id, &trader, n); + let held = pos.balances.get(outcome_index).unwrap_or(0); + pos.balances.set( + outcome_index, + held.checked_add(q.shares_out).ok_or(Error::Overflow)?, + ); + Self::save_cat_position(&env, market_id, &trader, &pos); + Self::save_cat_market(&env, market_id, &m); + + // interaction + Self::token(&env)?.transfer(&trader, env.current_contract_address(), &collateral_in); + + CatTrade { + market_id, + trader, + outcome_index, + is_buy: true, + collateral: collateral_in, + shares: q.shares_out, + fee: q.fee, + reserves: m.reserves, + } + .publish(&env); + Ok(q.shares_out) + } + + /// Categorical-market counterpart of `sell`: receive exactly + /// `collateral_out` by selling `outcome_index` shares. Returns shares + /// sold; reverts if more than `max_shares_in` would be needed. + pub fn sell_cat( + env: Env, + trader: Address, + market_id: u64, + outcome_index: u32, + collateral_out: i128, + max_shares_in: i128, + ) -> Result { + Self::assert_not_paused(&env)?; + trader.require_auth(); + let mut m = Self::load_cat_market(&env, market_id)?; + Self::assert_tradable_cat(&env, &m)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + if outcome_index >= n { + return Err(Error::InvalidOutcomeIndex); + } + let q = math_categorical::calc_sell( + &reserves[..n as usize], + outcome_index, + collateral_out, + m.fee_bps, + )?; + if q.shares_in > max_shares_in { + return Err(Error::SlippageExceeded); + } + + let mut pos = Self::load_cat_position(&env, market_id, &trader, n); + let held = pos.balances.get(outcome_index).unwrap_or(0); + if held < q.shares_in { + return Err(Error::InsufficientShares); + } + + // effects + pos.balances.set(outcome_index, held - q.shares_in); + Self::save_cat_position(&env, market_id, &trader, &pos); + m.reserves = Vec::from_slice(&env, &q.new_reserves[..n as usize]); + m.sets_minted -= q.gross; + let protocol_fee = Self::accrue_fees(&env, market_id, q.fee)?; + m.fees_accrued = m + .fees_accrued + .checked_add(protocol_fee) + .ok_or(Error::Overflow)?; + Self::save_cat_market(&env, market_id, &m); + + // interaction + Self::token(&env)?.transfer(&env.current_contract_address(), &trader, &collateral_out); + + CatTrade { + market_id, + trader, + outcome_index, + is_buy: false, + collateral: collateral_out, + shares: q.shares_in, + fee: q.fee, + reserves: m.reserves, + } + .publish(&env); + Ok(q.shares_in) + } + // ----- resolution ----------------------------------------------------- // Only the configured resolver contract may call these. When the resolver // contract is the direct caller, `require_auth` on its address passes. @@ -854,23 +1459,91 @@ impl MarketCore { Ok(()) } - /// Invalid or unresolvable market. Allowed any time before finality. - pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { + /// Categorical-market counterpart of `resolve`. Same resolver-only gate + /// and close-time rule. + pub fn resolve_cat(env: Env, market_id: u64, outcome_index: u32) -> Result<(), Error> { + Self::require_resolver(&env)?; + let mut m = Self::load_cat_market(&env, market_id)?; + if m.status != CatStatus::Open { + return Err(Error::MarketNotOpen); + } + if env.ledger().timestamp() < m.close_time { + return Err(Error::NotClosedYet); + } + if outcome_index >= m.reserves.len() { + return Err(Error::InvalidOutcomeIndex); + } + m.status = CatStatus::Resolved(outcome_index); + Self::save_cat_market(&env, market_id, &m); + CatMarketResolved { + market_id, + outcome_index: Some(outcome_index), + } + .publish(&env); + Ok(()) + } + + /// Scalar-market counterpart of `resolve`. `payout_long_bps` is the + /// final LONG payout in basis points of 1.0 (0..=10_000); the SHORT side + /// pays the complement. Same resolver-only gate and close-time rule. + pub fn resolve_scalar(env: Env, market_id: u64, payout_long_bps: u32) -> Result<(), Error> { Self::require_resolver(&env)?; let mut m = Self::load_market(&env, market_id)?; if m.status != MarketStatus::Open { return Err(Error::MarketNotOpen); } - m.status = MarketStatus::Void; + if env.ledger().timestamp() < m.close_time { + return Err(Error::NotClosedYet); + } + if payout_long_bps as i128 > math::BPS { + return Err(Error::InvalidProbability); + } + m.status = MarketStatus::ResolvedScalar(payout_long_bps); Self::save_market(&env, market_id, &m); - MarketResolved { + MarketResolvedScalar { market_id, - outcome: None, + payout_long_bps, } .publish(&env); Ok(()) } + /// Invalid or unresolvable market. Allowed any time before finality. + /// Dispatches on the market kind: binary markets emit `MarketResolved` + /// with `None`, categorical markets `CatMarketResolved`. + pub fn void_market(env: Env, market_id: u64) -> Result<(), Error> { + Self::require_resolver(&env)?; + match Self::market_kind_of(&env, market_id)? { + MarketKind::Categorical(_) => { + let mut m = Self::load_cat_market(&env, market_id)?; + if m.status != CatStatus::Open { + return Err(Error::MarketNotOpen); + } + m.status = CatStatus::Void; + Self::save_cat_market(&env, market_id, &m); + CatMarketResolved { + market_id, + outcome_index: None, + } + .publish(&env); + } + MarketKind::Binary => { + let mut m = Self::load_market(&env, market_id)?; + if m.status != MarketStatus::Open { + return Err(Error::MarketNotOpen); + } + m.status = MarketStatus::Void; + Self::save_market(&env, market_id, &m); + MarketResolved { + market_id, + outcome: None, + } + .publish(&env); + } + } + Ok(()) + } + // ----- settlement ----------------------------------------------------- /// Pays out `holder`'s position for a final market and clears it. @@ -929,21 +1602,91 @@ impl MarketCore { Ok(payout) } + /// Categorical-market counterpart of `redeem`. Winning balances pay 1:1, + /// a void pays `floor(Σ balances / n)` — both floored, so the total paid + /// across all holders never exceeds `sets_minted`. + pub fn redeem_cat(env: Env, holder: Address, market_id: u64) -> Result { + holder.require_auth(); + let m = Self::load_cat_market(&env, market_id)?; + let settled = Self::cat_settlement(&m)?; + let (_, n) = Self::cat_array(&m.reserves)?; + let pos = Self::load_cat_position(&env, market_id, &holder, n); + if pos.balances.iter().all(|b| b == 0) { + return Err(Error::NothingToRedeem); + } + let (balances, _) = Self::cat_array(&pos.balances)?; + let payout = math_categorical::settle_value(settled, &balances[..n as usize])?; + + Self::save_cat_position( + &env, + market_id, + &holder, + &CatPosition { + balances: Self::zero_reserves(&env, n), + }, + ); + if payout > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &holder, &payout); + } + Redeemed { + market_id, + holder, + payout, + } + .publish(&env); + Ok(payout) + } + + /// Categorical-market counterpart of `claim_pool_remainder`: the creator + /// (the LP) withdraws the settled pool value plus any unclaimed LP fees. + pub fn claim_pool_remainder_cat(env: Env, market_id: u64) -> Result { + let mut m = Self::load_cat_market(&env, market_id)?; + m.creator.require_auth(); + let settled = Self::cat_settlement(&m)?; + if m.lp_claimed { + return Err(Error::AlreadyClaimed); + } + let (reserves, n) = Self::cat_array(&m.reserves)?; + let pool_value = math_categorical::settle_value(settled, &reserves[..n as usize])?; + let payout = pool_value + .checked_add(Self::load_lp_fees(&env, market_id)) + .ok_or(Error::Overflow)?; + Self::save_lp_fees(&env, market_id, 0); + m.reserves = Self::zero_reserves(&env, n); + m.lp_claimed = true; + let creator = m.creator.clone(); + Self::save_cat_market(&env, market_id, &m); + if payout > 0 { + Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &payout); + } + PoolRemainderClaimed { + market_id, + creator, + payout, + } + .publish(&env); + Ok(payout) + } + /// The creator (the LP) claims their share of accrued trade fees at any /// time, including before finality. Returns the amount claimed (0 when - /// nothing is pending, mirroring `sweep_fees`). + /// nothing is pending, mirroring `sweep_fees`). Works for both market + /// kinds (the fee side-entries are keyed by the shared market id). pub fn claim_lp_fees(env: Env, market_id: u64) -> Result { - let m = Self::load_market(&env, market_id)?; - m.creator.require_auth(); + let creator = match Self::market_kind_of(&env, market_id)? { + MarketKind::Binary => Self::load_market(&env, market_id)?.creator, + MarketKind::Categorical(_) => Self::load_cat_market(&env, market_id)?.creator, + }; + creator.require_auth(); let amount = Self::load_lp_fees(&env, market_id); if amount <= 0 { return Ok(0); } Self::save_lp_fees(&env, market_id, 0); - Self::token(&env)?.transfer(&env.current_contract_address(), &m.creator, &amount); + Self::token(&env)?.transfer(&env.current_contract_address(), &creator, &amount); LpFeesClaimed { market_id, - creator: m.creator.clone(), + creator, amount, } .publish(&env); @@ -955,14 +1698,30 @@ impl MarketCore { /// Sends to the recipient snapshotted in `FeeTerms` at creation. Markets /// created before `FeeTerms` existed have none and keep the legacy /// behaviour: the `DataKey::Treasury` of the moment of the sweep. + /// Works for both market kinds. pub fn sweep_fees(env: Env, market_id: u64) -> Result { - let mut m = Self::load_market(&env, market_id)?; - let amount = m.fees_accrued; - if amount <= 0 { - return Ok(0); - } - m.fees_accrued = 0; - Self::save_market(&env, market_id, &m); + let amount = match Self::market_kind_of(&env, market_id)? { + MarketKind::Binary => { + let mut m = Self::load_market(&env, market_id)?; + let amount = m.fees_accrued; + if amount <= 0 { + return Ok(0); + } + m.fees_accrued = 0; + Self::save_market(&env, market_id, &m); + amount + } + MarketKind::Categorical(_) => { + let mut m = Self::load_cat_market(&env, market_id)?; + let amount = m.fees_accrued; + if amount <= 0 { + return Ok(0); + } + m.fees_accrued = 0; + Self::save_cat_market(&env, market_id, &m); + amount + } + }; let recipient = match Self::load_fee_terms(&env, market_id) { Some(t) => t.recipient, None => Self::instance_get(&env, &DataKey::Treasury)?, @@ -1083,10 +1842,47 @@ impl MarketCore { Self::load_market(&env, market_id) } + /// Display metadata (the staked range) of a scalar market. Absent for + /// binary and categorical markets. + pub fn get_scalar_meta(env: Env, market_id: u64) -> Result { + let key = DataKey::Scalar(market_id); + let meta: ScalarMeta = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::MarketNotFound)?; + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(meta) + } + pub fn get_position(env: Env, market_id: u64, holder: Address) -> Position { Self::load_position(&env, market_id, &holder) } + /// Whether a market id is a binary (`Market`) or categorical + /// (`CatMarket`) market. `MarketNotFound` for unknown ids. + pub fn market_kind(env: Env, market_id: u64) -> Result { + Self::market_kind_of(&env, market_id) + } + + pub fn get_cat_market(env: Env, market_id: u64) -> Result { + Self::load_cat_market(&env, market_id) + } + + /// A holder's categorical position, zero-filled to the market's outcome + /// count when they never traded. Errors if the market does not exist. + pub fn get_cat_position( + env: Env, + market_id: u64, + holder: Address, + ) -> Result { + let m = Self::load_cat_market(&env, market_id)?; + let n = m.reserves.len(); + Ok(Self::load_cat_position(&env, market_id, &holder, n)) + } + /// Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. pub fn get_price(env: Env, market_id: u64, outcome: Outcome) -> Result { let m = Self::load_market(&env, market_id)?; @@ -1098,7 +1894,8 @@ impl MarketCore { Ok(p as u32) } - /// Shares a `buy` of `collateral_in` would return right now. + /// Shares a `buy` of `collateral_in` would return right now, at the + /// effective (possibly dynamic) fee for this ledger's timestamp. pub fn quote_buy( env: Env, market_id: u64, @@ -1106,11 +1903,31 @@ impl MarketCore { collateral_in: i128, ) -> Result { let m = Self::load_market(&env, market_id)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let (r_bought, r_other) = Self::reserves_for(&m, outcome); + Ok(math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?.shares_out) + } + + /// Shares a `buy` of `collateral_in` would return at ledger timestamp + /// `at_timestamp`. Under a ramping schedule the fee rises with time, so + /// clients expecting inclusion latency can quote the worst case here + /// (see `docs/frontend-integration.md`). No other state is simulated: + /// the reserves are the current ones. + pub fn quote_buy_at( + env: Env, + market_id: u64, + outcome: Outcome, + collateral_in: i128, + at_timestamp: u64, + ) -> Result { + let m = Self::load_market(&env, market_id)?; + let fee_bps = Self::fee_at(&env, market_id, &m, at_timestamp)?; let (r_bought, r_other) = Self::reserves_for(&m, outcome); - Ok(math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?.shares_out) + Ok(math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?.shares_out) } - /// Shares a `sell` for exactly `collateral_out` would consume right now. + /// Shares a `sell` for exactly `collateral_out` would consume right now, + /// at the effective (possibly dynamic) fee for this ledger's timestamp. pub fn quote_sell( env: Env, market_id: u64, @@ -1118,8 +1935,41 @@ impl MarketCore { collateral_out: i128, ) -> Result { let m = Self::load_market(&env, market_id)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; let (r_sold, r_other) = Self::reserves_for(&m, outcome); - Ok(math::calc_sell(r_sold, r_other, collateral_out, m.fee_bps)?.shares_in) + Ok(math::calc_sell(r_sold, r_other, collateral_out, fee_bps)?.shares_in) + } + + /// Shares a `buy_cat` of `collateral_in` would return right now. + pub fn quote_buy_cat( + env: Env, + market_id: u64, + outcome_index: u32, + collateral_in: i128, + ) -> Result { + let m = Self::load_cat_market(&env, market_id)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + if outcome_index >= n { + return Err(Error::InvalidOutcomeIndex); + } + Ok(math_categorical::calc_buy( + &reserves[..n as usize], + outcome_index, + collateral_in, + m.fee_bps, + )? + .shares_out) + } + + /// Spot prices of all outcomes in bps, same order as the reserves. Each + /// price is floored, so they sum to at most BPS and at least BPS - n; + /// clients should treat the slack as display rounding, never as a + /// mispricing (prices do not enter trade math). + pub fn get_prices_cat(env: Env, market_id: u64) -> Result, Error> { + let m = Self::load_cat_market(&env, market_id)?; + let (reserves, n) = Self::cat_array(&m.reserves)?; + let prices = math_categorical::prices_bps(&reserves[..n as usize])?; + Ok(Vec::from_slice(&env, &prices[..n as usize])) } /// The market's snapshotted breakers (`None` for legacy markets created @@ -1129,6 +1979,28 @@ impl MarketCore { Ok(Self::load_breaker(&env, market_id)) } + /// The market's snapshotted dynamic fee schedule (`None` for legacy and + /// categorical markets, which charge the flat `Market::fee_bps`). + pub fn fee_schedule(env: Env, market_id: u64) -> Result, Error> { + Self::load_market(&env, market_id)?; + Ok(Self::load_fee_schedule(&env, market_id)) + } + + /// The fee bps this ledger's next trade on `market_id` pays: the + /// dynamic effective fee for binary markets with a schedule, the flat + /// snapshot for legacy and categorical markets. Under a ramping + /// schedule the value changes with the ledger timestamp; use + /// `quote_buy_at` to price expected inclusion latency. + pub fn current_fee_bps(env: Env, market_id: u64) -> Result { + match Self::market_kind_of(&env, market_id)? { + MarketKind::Binary => { + let m = Self::load_market(&env, market_id)?; + Self::fee_at(&env, market_id, &m, env.ledger().timestamp()) + } + MarketKind::Categorical(_) => Ok(Self::load_cat_market(&env, market_id)?.fee_bps), + } + } + /// The current ledger's band reference, if a band check has run this /// ledger. `None` means the next trade sets the reference (to its own /// pre-trade price, which equals the ledger-open price). @@ -1163,7 +2035,8 @@ impl MarketCore { return Ok(false); } let (r_bought, r_other) = Self::reserves_for(&m, outcome); - let q = math::calc_buy(r_bought, r_other, collateral_in, m.fee_bps)?; + let fee_bps = Self::fee_at(&env, market_id, &m, env.ledger().timestamp())?; + let q = math::calc_buy(r_bought, r_other, collateral_in, fee_bps)?; let pre = math::price_yes_bps(m.reserve_yes, m.reserve_no)?; let post = math::price_after( matches!(outcome, Outcome::Yes), @@ -1246,6 +2119,9 @@ impl MarketCore { MarketStatus::Resolved(Outcome::Yes) => Ok(Settlement::Yes), MarketStatus::Resolved(Outcome::No) => Ok(Settlement::No), MarketStatus::Void => Ok(Settlement::Void), + MarketStatus::ResolvedScalar(payout_long_bps) => { + Ok(Settlement::Scalar(payout_long_bps)) + } } } @@ -1291,17 +2167,127 @@ impl MarketCore { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - fn load_position(env: &Env, id: u64, holder: &Address) -> Position { - let key = DataKey::Position(id, holder.clone()); - match env.storage().persistent().get::<_, Position>(&key) { - Some(p) => { - env.storage() - .persistent() - .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); - p - } - None => Position { yes: 0, no: 0 }, - } + /// Which pool an id refers to: the `Kind` side entry when present + /// (categorical), otherwise a stored binary `Market`, otherwise unknown. + fn market_kind_of(env: &Env, id: u64) -> Result { + let key = DataKey::Kind(id); + if let Some(kind) = env.storage().persistent().get::<_, MarketKind>(&key) { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + return Ok(kind); + } + Self::load_market(env, id).map(|_| MarketKind::Binary) + } + + fn save_kind(env: &Env, id: u64, kind: &MarketKind) { + let key = DataKey::Kind(id); + env.storage().persistent().set(&key, kind); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + fn load_cat_market(env: &Env, id: u64) -> Result { + let key = DataKey::CatMarket(id); + let m: CatMarket = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::MarketNotFound)?; + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + Ok(m) + } + + fn save_cat_market(env: &Env, id: u64, m: &CatMarket) { + let key = DataKey::CatMarket(id); + env.storage().persistent().set(&key, m); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + /// Reads a categorical reserve/balance vector into a fixed array and + /// returns `(array, n)`. The length check is unreachable for stored + /// values (creation validated them) but keeps malformed storage from + /// panicking. + fn cat_array(v: &Vec) -> Result<([i128; MAX_N], u32), Error> { + let n = v.len(); + if !(2..=math_categorical::MAX_MARKET_OUTCOMES).contains(&n) { + return Err(Error::InvalidAmount); + } + let mut arr = [0i128; MAX_N]; + for (i, r) in v.iter().enumerate() { + arr[i] = r; + } + Ok((arr, n)) + } + + fn zero_reserves(env: &Env, n: u32) -> Vec { + Vec::from_slice(env, &[0i128; MAX_N][..n as usize]) + } + + fn load_cat_position(env: &Env, id: u64, holder: &Address, n: u32) -> CatPosition { + let key = DataKey::CatPosition(id, holder.clone()); + match env.storage().persistent().get::<_, CatPosition>(&key) { + Some(p) => { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + p + } + None => CatPosition { + balances: Self::zero_reserves(env, n), + }, + } + } + + /// Empty positions are removed so storage does not grow with dust. + fn save_cat_position(env: &Env, id: u64, holder: &Address, p: &CatPosition) { + let key = DataKey::CatPosition(id, holder.clone()); + if p.balances.iter().all(|b| b == 0) { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, p); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + } + + fn assert_tradable_cat(env: &Env, m: &CatMarket) -> Result<(), Error> { + if m.status != CatStatus::Open { + return Err(Error::MarketNotOpen); + } + if env.ledger().timestamp() >= m.close_time { + return Err(Error::MarketClosed); + } + Ok(()) + } + + /// `Some(winning index)` when final, `None` for a void (which settles + /// proportionally), `NotFinal` while open. + fn cat_settlement(m: &CatMarket) -> Result, Error> { + match m.status { + CatStatus::Open => Err(Error::NotFinal), + CatStatus::Resolved(w) => Ok(Some(w)), + CatStatus::Void => Ok(None), + } + } + + fn load_position(env: &Env, id: u64, holder: &Address) -> Position { + let key = DataKey::Position(id, holder.clone()); + match env.storage().persistent().get::<_, Position>(&key) { + Some(p) => { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + p + } + None => Position { yes: 0, no: 0 }, + } } /// Empty positions are removed so storage does not grow with dust entries. @@ -1391,6 +2377,69 @@ impl MarketCore { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } + /// Shared bounds for any `FeeSchedule`: `base <= max <= cap`, the hard + /// `MAX_FEE_BPS` ceiling, and the ramp/skew caps. `cap` is `MAX_FEE_BPS` + /// for the default setter and the current default's `max_bps` for + /// creator-supplied schedules, so a creator can only tighten policy. + fn validate_schedule(s: &FeeSchedule, cap_bps: u32) -> Result<(), Error> { + if s.base_bps > s.max_bps || s.max_bps > MAX_FEE_BPS || s.max_bps > cap_bps { + return Err(Error::InvalidFee); + } + if s.ramp_secs > math::MAX_RAMP_SECS || s.skew_mult_bps > math::MAX_SKEW_MULT_BPS { + return Err(Error::InvalidFee); + } + Ok(()) + } + + fn load_fee_schedule(env: &Env, id: u64) -> Option { + let key = DataKey::FeeSchedule(id); + let s: Option = env.storage().persistent().get(&key); + if s.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + s + } + + fn save_fee_schedule(env: &Env, id: u64, s: &FeeSchedule) { + let key = DataKey::FeeSchedule(id); + env.storage().persistent().set(&key, s); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + } + + fn fee_schedule_m(s: &FeeSchedule) -> math::FeeScheduleM { + math::FeeScheduleM { + base_bps: s.base_bps, + max_bps: s.max_bps, + ramp_secs: s.ramp_secs, + skew_mult_bps: s.skew_mult_bps, + } + } + + /// The fee rate for this market's next binary trade at time `now`: the + /// snapshotted `FeeSchedule` evaluated against the pre-trade reserves, + /// or the flat `fee_bps` for markets without one. The result is always + /// `< BPS`, which `calc_sell` requires. + fn fee_at(env: &Env, id: u64, m: &Market, now: u64) -> Result { + let fee = match Self::load_fee_schedule(env, id) { + Some(s) => math::effective_fee_bps( + &Self::fee_schedule_m(&s), + now, + m.close_time, + m.reserve_yes, + m.reserve_no, + )?, + None => m.fee_bps, + }; + if fee as i128 >= BPS { + return Err(Error::InvalidFee); + } + Ok(fee) + } + /// Runs the market's snapshotted breakers against a quote's /// outcome-relative post-trade reserves. Absent entry (legacy market) or /// an all-zero config means no checks. @@ -2729,4 +3778,1312 @@ mod test { client.claim_pool_remainder(&market_id); client.transfer_lp(&market_id, &Address::generate(&env)); } + + // ----- #6: categorical (N-outcome) markets ----------------------------- + + /// One 100 USDC categorical market opening at `probs` (bps per outcome). + fn new_cat_market( + env: &Env, + client: &MarketCoreClient, + creator: &Address, + probs: &[u32], + ) -> u64 { + let mut p = Vec::new(env); + for x in probs { + p.push_back(*x); + } + client.create_categorical_market( + creator, + &BytesN::from_array(env, &[43u8; 32]), + &BytesN::from_array(env, &[143u8; 32]), + &String::from_str(env, "ipfs://test-cat-market"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &p, + ) + } + + /// Contract balance always covers every outstanding claim: per outcome, + /// `reserve_i + Σ holder balances_i == sets_minted`, so the collateral + /// held equals `sets_minted + fees_accrued + lp_fees` while nothing has + /// been claimed yet. + fn assert_cat_solvent( + env: &Env, + client: &MarketCoreClient, + collateral: &Address, + market_id: u64, + ) { + let m = client.get_cat_market(&market_id); + let balance = + soroban_sdk::token::TokenClient::new(env, collateral).balance(&client.address); + assert_eq!( + balance, + m.sets_minted + m.fees_accrued + lp_fees_stored(env, client, market_id), + "cat market solvency broken" + ); + } + + #[test] + fn create_categorical_market_and_trade() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 3000, 2000]); + + // shared id space, categorical kind, binary views reject the id + assert_eq!(client.market_count(), 1); + assert_eq!(client.market_kind(&market_id), MarketKind::Categorical(3)); + assert_eq!( + client.try_get_market(&market_id), + Err(Ok(Error::MarketNotFound)) + ); + assert!(client.try_get_cat_market(&market_id).is_ok()); + + // seeded reserves and opening prices mirror the requested probabilities + let m = client.get_cat_market(&market_id); + assert_eq!( + m.reserves, + Vec::from_slice(&env, &[400_000_000_i128, 666_666_666, 1_000_000_000]) + ); + assert_eq!(m.sets_minted, 1_000_000_000); + // display prices land within a bps or two of the request (reciprocal + // rounding), and the floor slack stays within n units of BPS + let prices = client.get_prices_cat(&market_id); + let want = [5000i128, 3000, 2000]; + let mut sum: i128 = 0; + for (i, p) in prices.iter().enumerate() { + assert!((p as i128 - want[i]).abs() <= 2, "i={i} price={p}"); + sum += p as i128; + } + assert!(sum <= BPS && sum + 3 >= BPS); + + // creator keeps shares of the two likelier outcomes only + let creator_pos = client.get_cat_position(&market_id, &creator); + assert_eq!( + creator_pos.balances, + Vec::from_slice(&env, &[600_000_000_i128, 333_333_334, 0]) + ); + // a never-traded address reads a zero-filled position of length n + let empty = client.get_cat_position(&market_id, &Address::generate(&env)); + assert_eq!(empty.balances, Vec::from_slice(&env, &[0_i128, 0, 0])); + assert_cat_solvent(&env, &client, &collateral, market_id); + + // quote matches execution + let quote = client.quote_buy_cat(&market_id, &0u32, &(10 * 10_000_000_i128)); + let shares = client.buy_cat(&trader, &market_id, &0u32, &(10 * 10_000_000_i128), &0_i128); + assert_eq!(shares, quote); + + // buy: fee = ceil(100e6 * 1%) = 1e6, net = 99e6; other reserves grow + // by net, the bought reserve absorbs net minus shares + let m = client.get_cat_market(&market_id); + let net = 99_000_000_i128; + assert_eq!(m.reserves.get(1), Some(666_666_666 + net)); + assert_eq!(m.reserves.get(2), Some(1_000_000_000 + net)); + assert_eq!(m.reserves.get(0), Some(400_000_000 + net - shares)); + assert_eq!(m.sets_minted, 1_000_000_000 + net); + assert_eq!(m.fees_accrued, 1_000_000); + assert_eq!(lp_fees_stored(&env, &client, market_id), 0); + let pos = client.get_cat_position(&market_id, &trader); + assert_eq!(pos.balances, Vec::from_slice(&env, &[shares, 0, 0])); + assert_cat_solvent(&env, &client, &collateral, market_id); + + // sell 5 USDC back: gross = ceil(50e6 / 0.99) = 50_505_051 + let sold = client.sell_cat( + &trader, + &market_id, + &0u32, + &(5 * 10_000_000_i128), + &i128::MAX, + ); + assert!(sold > 0 && sold < shares); + let m = client.get_cat_market(&market_id); + assert_eq!(m.sets_minted, 1_099_000_000 - 50_505_051); + assert_eq!(m.fees_accrued, 1_000_000 + 505_051); + let pos = client.get_cat_position(&market_id, &trader); + assert_eq!(pos.balances.get(0), Some(shares - sold)); + assert_cat_solvent(&env, &client, &collateral, market_id); + } + + #[test] + fn cat_redeem_after_resolution() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 3000, 2000]); + let shares = client.buy_cat(&trader, &market_id, &2u32, &(10 * 10_000_000_i128), &0_i128); + + // settlement is resolver-only after close + assert_eq!( + client.try_resolve_cat(&market_id, &2u32), + Err(Ok(Error::NotClosedYet)) + ); + assert_eq!( + client.try_redeem_cat(&trader, &market_id), + Err(Ok(Error::NotFinal)) + ); + assert_eq!( + client.try_claim_pool_remainder_cat(&market_id), + Err(Ok(Error::NotFinal)) + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_cat(&market_id, &2u32); + assert_eq!( + client.get_cat_market(&market_id).status, + CatStatus::Resolved(2) + ); + + // winner pays 1:1, loser pays 0, then the pool leftover goes to the LP + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + assert_eq!(client.redeem_cat(&trader, &market_id), shares); + assert_eq!(token.balance(&trader), before + shares); + // creator holds [600e6, 333_333_334, 0]: nothing on the winning index + assert_eq!(client.redeem_cat(&creator, &market_id), 0); + assert_eq!( + client.try_redeem_cat(&trader, &market_id), + Err(Ok(Error::NothingToRedeem)) + ); + + let pool = client.get_cat_market(&market_id).reserves.get(2).unwrap(); + assert_eq!(client.claim_pool_remainder_cat(&market_id), pool); + assert_eq!( + client.try_claim_pool_remainder_cat(&market_id), + Err(Ok(Error::AlreadyClaimed)) + ); + // only the unclaimed protocol fees remain locked in the contract + let m = client.get_cat_market(&market_id); + assert_eq!(token.balance(&client.address), m.fees_accrued); + let treasury_balance_before = token.balance(&treasury_of(&client)); + assert_eq!(client.sweep_fees(&market_id), m.fees_accrued); + assert_eq!(token.balance(&client.address), 0); + assert_eq!( + token.balance(&treasury_of(&client)), + treasury_balance_before + m.fees_accrued + ); + } + + fn treasury_of(client: &MarketCoreClient) -> Address { + client.get_config().treasury + } + + #[test] + fn cat_void_pays_proportional() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[2500, 2500, 2500, 2500]); + let m = client.get_cat_market(&market_id); + assert_eq!(m.reserves, Vec::from_slice(&env, &[1_000_000_000_i128; 4])); + // flat probabilities leave the creator no leftover shares at all + let creator_pos = client.get_cat_position(&market_id, &creator); + assert_eq!(creator_pos.balances, Vec::from_slice(&env, &[0_i128; 4])); + + let shares = client.buy_cat(&trader, &market_id, &1u32, &(10 * 10_000_000_i128), &0_i128); + client.void_market(&market_id); + assert_eq!(client.get_cat_market(&market_id).status, CatStatus::Void); + + // void pays floor(balances / n) per holder + assert_eq!(client.redeem_cat(&trader, &market_id), shares / 4); + assert_eq!( + client.try_redeem_cat(&creator, &market_id), + Err(Ok(Error::NothingToRedeem)) + ); + let reserves = client.get_cat_market(&market_id).reserves; + let pool = (reserves.get(0).unwrap() + + reserves.get(1).unwrap() + + reserves.get(2).unwrap() + + reserves.get(3).unwrap()) + / 4; + assert_eq!(client.claim_pool_remainder_cat(&market_id), pool); + client.sweep_fees(&market_id); + // floors can strand at most one base unit per void settlement + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + assert!(token.balance(&client.address) <= 1); + } + + #[test] + fn wrong_caller_cannot_resolve_cat() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 5000]); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + let attacker = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &attacker, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "resolve_cat", + args: (market_id, 0u32).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_resolve_cat(&market_id, &0u32) + .is_err()); + } + + #[test] + fn cat_trade_and_settlement_validations() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 3000, 2000]); + + // outcome index must be < n on every entrypoint that takes one + assert_eq!( + client.try_buy_cat(&trader, &market_id, &3u32, &(10_000_000_i128), &0_i128), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + assert_eq!( + client.try_quote_buy_cat(&market_id, &3u32, &(10_000_000_i128)), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + assert_eq!( + client.try_sell_cat(&trader, &market_id, &9u32, &(1_000_000_i128), &i128::MAX), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + + // slippage and share checks + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &i128::MAX), + Err(Ok(Error::SlippageExceeded)) + ); + assert_eq!( + client.try_sell_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &i128::MAX), + Err(Ok(Error::InsufficientShares)) + ); + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &0_i128, &0_i128), + Err(Ok(Error::InvalidAmount)) + ); + + // after close: trading stops, resolution validates the index first + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &0_i128), + Err(Ok(Error::MarketClosed)) + ); + assert_eq!( + client.try_resolve_cat(&market_id, &3u32), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + client.resolve_cat(&market_id, &0u32); + // status is checked before the index, so a second resolve reports + // MarketNotOpen even with an out-of-range index + assert_eq!( + client.try_resolve_cat(&market_id, &9u32), + Err(Ok(Error::MarketNotOpen)) + ); + assert_eq!( + client.try_buy_cat(&trader, &market_id, &0u32, &(10_000_000_i128), &0_i128), + Err(Ok(Error::MarketNotOpen)) + ); + assert_eq!( + client.try_void_market(&market_id), + Err(Ok(Error::MarketNotOpen)) + ); + } + + #[test] + fn cat_create_validations() { + let (env, client, creator, _, _, _) = setup_market(); + + // sum must be exactly BPS + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[6000u32, 3000]), + ), + Err(Ok(Error::InvalidProbability)) + ); + // every probability within MIN..=MAX + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[9950u32, 50]), + ), + Err(Ok(Error::InvalidProbability)) + ); + // outcome count must be 2..=8 + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[10_000u32]), + ), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[1000u32; 9]), + ), + Err(Ok(Error::InvalidAmount)) + ); + // seed below the shared MIN_SEED + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &(env.ledger().timestamp() + 60_000), + &1_000_000_i128, + &Vec::from_slice(&env, &[5000u32, 5000]), + ), + Err(Ok(Error::InvalidAmount)) + ); + // past close time + assert_eq!( + client.try_create_categorical_market( + &creator, + &BytesN::from_array(&env, &[43u8; 32]), + &BytesN::from_array(&env, &[143u8; 32]), + &String::from_str(&env, "ipfs://x"), + &Category::Crypto, + &env.ledger().timestamp(), + &1_000_000_000_i128, + &Vec::from_slice(&env, &[5000u32, 5000]), + ), + Err(Ok(Error::InvalidCloseTime)) + ); + + // boundary probabilities are accepted + let market_id = new_cat_market(&env, &client, &creator, &[100, 9900]); + let m = client.get_cat_market(&market_id); + assert_eq!( + m.reserves, + Vec::from_slice(&env, &[1_000_000_000_i128, 10_101_010]) + ); + let prices = client.get_prices_cat(&market_id); + let sum: u32 = prices.iter().sum(); + assert!((sum as i128) + 2 >= BPS && (sum as i128) <= BPS); + } + + #[test] + fn cat_fee_split_and_sweep_dispatch() { + let (env, client, creator, trader, _, collateral) = setup_market_full(5000, None); + let market_id = new_cat_market(&env, &client, &creator, &[5000, 5000]); + client.buy_cat(&trader, &market_id, &0u32, &(10 * 10_000_000_i128), &0_i128); + + // fee = 1e6, protocol = ceil(1e6 * 50%) = 5e5, LP = 5e5 + let m = client.get_cat_market(&market_id); + assert_eq!(m.fees_accrued, 500_000); + assert_eq!(lp_fees_stored(&env, &client, market_id), 500_000); + + // claim_lp_fees works on categorical ids; second call is a no-op + assert_eq!(client.claim_lp_fees(&market_id), 500_000); + assert_eq!(client.claim_lp_fees(&market_id), 0); + + // sweep_fees sends the protocol part to the treasury + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let treasury = treasury_of(&client); + let before = token.balance(&treasury); + assert_eq!(client.sweep_fees(&market_id), 500_000); + assert_eq!(token.balance(&treasury), before + 500_000); + } + + #[test] + fn cat_eight_outcomes_smoke() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[1250; 8]); + assert_eq!(client.market_kind(&market_id), MarketKind::Categorical(8)); + let prices = client.get_prices_cat(&market_id); + assert_eq!(prices, Vec::from_slice(&env, &[1250u32; 8])); + + // one small buy per outcome exercises every branch of the product + let sizes = [1i128, 2, 3, 4, 5, 6, 7, 8]; + for (i, lots) in sizes.iter().enumerate() { + let shares = client.buy_cat( + &trader, + &market_id, + &(i as u32), + &(lots * 10_000_000_i128), + &0_i128, + ); + assert!(shares > 0); + } + assert_cat_solvent(&env, &client, &collateral, market_id); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_cat(&market_id, &7u32); + let pos = client.get_cat_position(&market_id, &trader); + assert_eq!( + client.redeem_cat(&trader, &market_id), + pos.balances.get(7).unwrap() + ); + client.claim_pool_remainder_cat(&market_id); + client.sweep_fees(&market_id); + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + assert_eq!(token.balance(&client.address), 0); + } + + /// The resource ask from the categorical issue: the widest single trade + /// (an 8-outcome buy — the longest mul_div loop and `Vec` writes) + /// metered against the mainnet per-transaction limits. Native-contract + /// numbers; the wasm build only adds VM overhead on top. + #[test] + fn cat_eight_outcomes_resource_budget() { + let (env, client, creator, trader, _, _) = setup_market(); + let market_id = new_cat_market(&env, &client, &creator, &[1250; 8]); + env.cost_estimate().budget().reset_unlimited(); + client.buy_cat(&trader, &market_id, &0u32, &(10 * 10_000_000_i128), &0_i128); + let used = env.cost_estimate().resources(); + std::println!( + "buy_cat n=8: {} instructions, {} memory bytes, {} write bytes, {} write entries", + used.instructions, + used.mem_bytes, + used.write_bytes, + used.write_entries + ); + // Mainnet per-transaction limits (soroban-sdk 27 + // `InvocationResourceLimits::mainnet`): 400M instructions, 40 MiB + // memory, 132_096 write bytes; stay under 60% of each. + assert!(used.instructions <= 240_000_000); + assert!(used.mem_bytes <= 25_165_824); + assert!(used.write_bytes <= 79_257); + } + + // ----- #7: scalar (range) markets -------------------------------------- + + /// One 100 USDC scalar market staked on the display range + /// `[100_000_000, 250_000_000]` (the `scalar_meta` golden vector values), + /// opening at 50%. + fn new_scalar_market(env: &Env, client: &MarketCoreClient, creator: &Address) -> u64 { + client.create_scalar_market( + creator, + &BytesN::from_array(env, &[44u8; 32]), + &BytesN::from_array(env, &[144u8; 32]), + &String::from_str(env, "ipfs://test-scalar-market"), + &Category::Macro, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &5000u32, + &100_000_000_i128, + &250_000_000_i128, + ) + } + + #[test] + fn create_scalar_market_validates_range_and_stores_meta() { + let (env, client, creator, _, _, _) = setup_market(); + for (lower, upper) in [(100_i128, 100_i128), (200_i128, 100_i128)] { + assert_eq!( + client.try_create_scalar_market( + &creator, + &BytesN::from_array(&env, &[44u8; 32]), + &BytesN::from_array(&env, &[144u8; 32]), + &String::from_str(&env, "ipfs://scalar"), + &Category::Macro, + &(env.ledger().timestamp() + 60_000), + &(100 * 10_000_000_i128), + &5000u32, + &lower, + &upper, + ), + Err(Ok(Error::InvalidAmount)) + ); + } + + let market_id = new_scalar_market(&env, &client, &creator); + assert_eq!( + client.get_scalar_meta(&market_id), + ScalarMeta { + lower: 100_000_000, + upper: 250_000_000, + } + ); + // a scalar market reuses the binary pool and position storage; only + // the final status and the display metadata tell it apart + assert_eq!(client.market_kind(&market_id), MarketKind::Binary); + assert!(client.try_get_cat_market(&market_id).is_err()); + // binary ids carry no scalar metadata + let binary = new_market(&env, &client, &creator, 5000); + assert_eq!( + client.try_get_scalar_meta(&binary), + Err(Ok(Error::MarketNotFound)) + ); + } + + #[test] + fn wrong_caller_cannot_resolve_scalar() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + + // not closed yet, even for the configured resolver + assert_eq!( + client.try_resolve_scalar(&market_id, &6_000u32), + Err(Ok(Error::NotClosedYet)) + ); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + let not_resolver = Address::generate(&env); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: ¬_resolver, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "resolve_scalar", + args: (market_id, 6_000u32).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_resolve_scalar(&market_id, &6_000u32) + .is_err()); + env.mock_all_auths(); + + client.resolve_scalar(&market_id, &6_250u32); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::ResolvedScalar(6_250) + ); + // a final market cannot be re-resolved + assert_eq!( + client.try_resolve_scalar(&market_id, &5_000u32), + Err(Ok(Error::MarketNotOpen)) + ); + } + + #[test] + fn scalar_redeem_long_and_short() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + client.buy( + &trader, + &market_id, + &Outcome::No, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_scalar(&market_id, &6_250u32); + + let pos = client.get_position(&market_id, &trader); + assert!(pos.yes > 0 && pos.no > 0); + // every share stays covered by the pool: reserve + positions == sets + let m = client.get_market(&market_id); + let lp = client.get_position(&market_id, &creator); + assert_eq!(m.reserve_yes + pos.yes + lp.yes, m.sets_minted); + assert_eq!(m.reserve_no + pos.no + lp.no, m.sets_minted); + + // both legs floor, exactly as `scalar_payout` computes them + let want = pos.yes * 6_250 / BPS + pos.no * 3_750 / BPS; + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let before = token.balance(&trader); + assert_eq!(client.redeem(&trader, &market_id), want); + assert_eq!(token.balance(&trader), before + want); + assert_eq!( + client.get_position(&market_id, &trader), + Position { yes: 0, no: 0 } + ); + assert_eq!( + client.try_redeem(&trader, &market_id), + Err(Ok(Error::NothingToRedeem)) + ); + } + + #[test] + fn scalar_claim_pool_remainder() { + let (env, client, creator, trader, _, collateral) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + client.buy( + &trader, + &market_id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_scalar(&market_id, &6_250u32); + + // the LP claims both reserves at the final payout; with the default + // all-protocol split there are no LP fees on top + let m = client.get_market(&market_id); + let want = m.reserve_yes * 6_250 / BPS + m.reserve_no * 3_750 / BPS; + let token = soroban_sdk::token::TokenClient::new(&env, &collateral); + let creator_before = token.balance(&creator); + assert_eq!(client.claim_pool_remainder(&market_id), want); + assert_eq!(token.balance(&creator), creator_before + want); + assert!(client.get_market(&market_id).lp_claimed); + assert_eq!( + client.try_claim_pool_remainder(&market_id), + Err(Ok(Error::AlreadyClaimed)) + ); + + // drain the market: only rounding dust may stay in the contract (the + // trader's and the pool's payouts each floor two legs, so the two + // losses sum to under two units). The creator holds no shares — at a + // 50/50 seed the reserves absorb the whole seed — so nothing else is + // outstanding. + client.redeem(&trader, &market_id); + client.sweep_fees(&market_id); + assert!(token.balance(&client.address) <= 1); + } + + #[test] + fn resolve_scalar_rejects_bps_above_max() { + let (env, client, creator, _, _, _) = setup_market(); + let market_id = new_scalar_market(&env, &client, &creator); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + + assert_eq!( + client.try_resolve_scalar(&market_id, &10_001u32), + Err(Ok(Error::InvalidProbability)) + ); + assert_eq!(client.get_market(&market_id).status, MarketStatus::Open); + + // the 0 and BPS boundaries are valid payouts + client.resolve_scalar(&market_id, &10_000u32); + assert_eq!( + client.get_market(&market_id).status, + MarketStatus::ResolvedScalar(10_000) + ); + } + + #[test] + fn scalar_extremes_match_binary_resolution() { + let (env, client, creator, trader, _, _) = setup_market(); + // four identical 5000 markets with identical long+short trades + let mut ids = [0u64; 4]; + for id in ids.iter_mut() { + *id = new_scalar_market(&env, &client, &creator); + client.buy(&trader, id, &Outcome::Yes, &(10 * 10_000_000_i128), &0_i128); + client.buy(&trader, id, &Outcome::No, &(5 * 10_000_000_i128), &0_i128); + } + env.ledger() + .set_timestamp(env.ledger().timestamp() + 60_001); + client.resolve_scalar(&ids[0], &0u32); + client.resolve(&ids[1], &Outcome::No); + client.resolve_scalar(&ids[2], &10_000u32); + client.resolve(&ids[3], &Outcome::Yes); + + let pos = |id: u64| client.get_position(&id, &trader); + assert_eq!(pos(ids[0]), pos(ids[1])); + assert_eq!(pos(ids[2]), pos(ids[3])); + + let short_wins = pos(ids[0]).no; + let long_wins = pos(ids[2]).yes; + let scalar_zero = client.redeem(&trader, &ids[0]); + let binary_no = client.redeem(&trader, &ids[1]); + let scalar_max = client.redeem(&trader, &ids[2]); + let binary_yes = client.redeem(&trader, &ids[3]); + assert_eq!(scalar_zero, binary_no); + assert_eq!(scalar_max, binary_yes); + assert_eq!(scalar_zero, short_wins); + assert_eq!(scalar_max, long_wins); + } + + // ----- #9: dynamic fee model (time-to-close + skew) --------------------- + + fn fee_sched(base_bps: u32, max_bps: u32, ramp_secs: u64, skew_mult_bps: u32) -> FeeSchedule { + FeeSchedule { + base_bps, + max_bps, + ramp_secs, + skew_mult_bps, + } + } + + #[test] + fn buy_uses_dynamic_fee_near_close() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(100, 1_000, 10_000, 0)); + let id = new_market(&env, &client, &creator, 5000); // closes at t = 60_000 + assert_eq!( + client.fee_schedule(&id), + Some(fee_sched(100, 1_000, 10_000, 0)), + "creation snapshots the default schedule" + ); + + // outside the ramp window (opens at close - 10_000): flat base fee + assert_eq!(client.current_fee_bps(&id), 100); + let buy = 10 * 10_000_000_i128; + let shares_a = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + // read events before any other invocation — only the trade's linger + let events_a = env.events().all().filter_by_contract(&client.address); + let m_a = client.get_market(&id); + assert_eq!(m_a.fees_accrued, 1_000_000); // ceil(1e8 * 100 / 1e4) + let trade_a = Trade { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_a, + fee: 1_000_000, + reserve_yes: m_a.reserve_yes, + reserve_no: m_a.reserve_no, + }; + let trade_a_v2 = TradeV2 { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_a, + fee: 1_000_000, + fee_bps: 100, + reserve_yes: m_a.reserve_yes, + reserve_no: m_a.reserve_no, + }; + assert_eq!( + events_a, + [ + soroban_sdk::Event::to_xdr(&trade_a, &env, &client.address), + soroban_sdk::Event::to_xdr(&trade_a_v2, &env, &client.address), + ], + "Trade and TradeV2 both describe the base-rate trade" + ); + + // 60s before close, inside the ramp: t = (10_000 - 60) / 10_000 of + // the 900 bps band -> 894, so the fee is 994 bps + env.ledger().set_timestamp(59_940); + assert_eq!(client.current_fee_bps(&id), 994); + let shares_b = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + let events_b = env.events().all().filter_by_contract(&client.address); + let m_b = client.get_market(&id); + assert_eq!(m_b.fees_accrued, 1_000_000 + 9_940_000); + let trade_b = Trade { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_b, + fee: 9_940_000, + reserve_yes: m_b.reserve_yes, + reserve_no: m_b.reserve_no, + }; + let trade_b_v2 = TradeV2 { + market_id: id, + trader: trader.clone(), + outcome: Outcome::Yes, + is_buy: true, + collateral: buy, + shares: shares_b, + fee: 9_940_000, + fee_bps: 994, + reserve_yes: m_b.reserve_yes, + reserve_no: m_b.reserve_no, + }; + assert_eq!( + events_b, + [ + soroban_sdk::Event::to_xdr(&trade_b, &env, &client.address), + soroban_sdk::Event::to_xdr(&trade_b_v2, &env, &client.address), + ], + "near close the same trade pays 10x the base fee" + ); + } + + #[test] + fn quote_buy_equals_buy_same_ledger() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(80, 600, 3_600, 250)); + let id = new_market(&env, &client, &creator, 5000); // closes at t = 60_000 + + // mid-ramp: t = 1_800/3_600 of the 520 bps band = 260 -> fee 340 + env.ledger().set_timestamp(58_200); + assert_eq!(client.current_fee_bps(&id), 340); + let buy = 7 * 10_000_000_i128; + let quoted = client.quote_buy(&id, &Outcome::Yes, &buy); + let shares = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + assert_eq!(quoted, shares, "quote and same-ledger buy must agree"); + + let sell_out = 10_000_000_i128; + let quoted_sell = client.quote_sell(&id, &Outcome::Yes, &sell_out); + let shares_in = client.sell(&trader, &id, &Outcome::Yes, &sell_out, &i128::MAX); + assert_eq!(quoted_sell, shares_in, "sell quote must agree too"); + } + + #[test] + fn legacy_market_keeps_flat_fee() { + let (env, client, creator, trader, _, _) = setup_market(); + let id = new_market(&env, &client, &creator, 5000); + assert_eq!(client.fee_schedule(&id), None, "no default -> no snapshot"); + + // a schedule set after creation never retro-fits existing markets + client.set_default_fee_schedule(&fee_sched(900, 1_000, 0, 1_000)); + assert_eq!(client.fee_schedule(&id), None); + assert_eq!(client.current_fee_bps(&id), 100); + + let shares = client.buy( + &trader, + &id, + &Outcome::Yes, + &(10 * 10_000_000_i128), + &0_i128, + ); + assert!(shares > 0); + assert_eq!(client.get_market(&id).fees_accrued, 1_000_000); + } + + #[test] + fn categorical_market_keeps_flat_fee() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(900, 1_000, 0, 1_000)); + let id = new_cat_market(&env, &client, &creator, &[5000, 5000]); + assert_eq!( + client.current_fee_bps(&id), + 100, + "categorical markets always charge their flat fee" + ); + client.buy_cat(&trader, &id, &0u32, &(10 * 10_000_000_i128), &0_i128); + assert_eq!(client.get_cat_market(&id).fees_accrued, 1_000_000); + } + + #[test] + fn set_default_fee_schedule_rejects_out_of_bounds() { + let (_env, client, _, _, _, _) = setup_market(); + let cases = [ + fee_sched(500, 499, 0, 0), // base > max + fee_sched(0, 1_001, 0, 0), // max > MAX_FEE_BPS + fee_sched(0, 500, math::MAX_RAMP_SECS + 1, 0), // ramp > 30 days + fee_sched(0, 500, 0, math::MAX_SKEW_MULT_BPS + 1), // skew > cap + ]; + for c in cases { + assert_eq!( + client.try_set_default_fee_schedule(&c), + Err(Ok(Error::InvalidFee)), + "schedule {c:?} must be rejected" + ); + } + // the boundary values themselves are accepted + client.set_default_fee_schedule(&fee_sched( + 0, + 1_000, + math::MAX_RAMP_SECS, + math::MAX_SKEW_MULT_BPS, + )); + } + + #[test] + fn wrong_caller_cannot_set_default_fee_schedule() { + let (env, client, _, trader, _, _) = setup_market(); + let schedule = fee_sched(10, 100, 0, 0); + env.set_auths(&[]); + assert!(client + .mock_auths(&[MockAuth { + address: &trader, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_default_fee_schedule", + args: (schedule,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_default_fee_schedule(&schedule) + .is_err()); + } + + #[test] + fn create_market_with_fees_snapshots_and_caps() { + let (env, client, creator, _, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(50, 500, 1_000, 100)); + let close = env.ledger().timestamp() + 60_000; + let args = |env: &Env, schedule: FeeSchedule| { + ( + BytesN::from_array(env, &[22u8; 32]), + BytesN::from_array(env, &[122u8; 32]), + String::from_str(env, "ipfs://explicit-fees"), + Category::Crypto, + close, + 100 * 10_000_000_i128, + 5000u32, + schedule, + ) + }; + + let explicit = fee_sched(20, 400, 0, 0); + let (q, r, u, c, s, seed, yes, sch) = args(&env, explicit); + let id = client.create_market_with_fees(&creator, &q, &r, &u, &c, &s, &seed, &yes, &sch); + assert_eq!(client.fee_schedule(&id), Some(explicit)); + assert_eq!(client.current_fee_bps(&id), 20); + + // above the protocol policy cap (default max 500) / hard cap / invert + for bad in [ + fee_sched(0, 501, 0, 0), + fee_sched(0, 1_001, 0, 0), + fee_sched(100, 50, 0, 0), + ] { + let (q, r, u, c, s, seed, yes, sch) = args(&env, bad); + assert_eq!( + client.try_create_market_with_fees(&creator, &q, &r, &u, &c, &s, &seed, &yes, &sch), + Err(Ok(Error::InvalidFee)), + "schedule {bad:?} must be rejected" + ); + } + // rejected attempts must not burn ids + assert_eq!(client.market_count(), 1); + } + + #[test] + fn quote_buy_at_simulates_future_fee() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(100, 1_000, 10_000, 0)); + let id = new_market(&env, &client, &creator, 5000); // closes at t = 60_000 + let buy = 5 * 10_000_000_i128; + + let now_quote = client.quote_buy(&id, &Outcome::Yes, &buy); + let late_quote = client.quote_buy_at(&id, &Outcome::Yes, &buy, &59_999); + assert!(late_quote < now_quote, "a later timestamp pays more fee"); + // quotes past close clamp to the ramp end instead of failing + let after_close = client.quote_buy_at(&id, &Outcome::Yes, &buy, &60_001); + assert!(after_close < late_quote); + + env.ledger().set_timestamp(59_999); + let actual = client.buy(&trader, &id, &Outcome::Yes, &buy, &0_i128); + assert_eq!(actual, late_quote, "quote_buy_at matches the later buy"); + } + + #[test] + fn split_trades_pay_at_least_single_trade_fee_minus_rounding() { + let (env, client, creator, trader, _, _) = setup_market(); + client.set_default_fee_schedule(&fee_sched(100, 1_000, 0, 1_000)); + let id_single = new_market(&env, &client, &creator, 5000); + let id_split = new_market(&env, &client, &creator, 5000); + let unit = 10_000_000_i128; + + client.buy(&trader, &id_single, &Outcome::Yes, &(10 * unit), &0_i128); + for _ in 0..10 { + client.buy(&trader, &id_split, &Outcome::Yes, &unit, &0_i128); + } + + let single_fee = client.get_market(&id_single).fees_accrued; + let split_fee = client.get_market(&id_split).fees_accrued; + // both pools open at exactly 50/50 and pay the base rate first, so + // the single 10-unit buy costs exactly 1_000_000 (100 bps of 1e8) + assert_eq!(single_fee, 1_000_000); + // every split piece pays >= the base rate, and each unit is an exact + // 1_000x scaling, so splitting can never undercut the lump sum + assert!( + split_fee >= single_fee, + "split {split_fee} < single {single_fee}" + ); + } +} + +/// Pins the exact XDR bytes behind stored binary-market state and the tag +/// order of every append-only enum. A change to field names, field order, +/// variant names, or the position of an appended variant changes the encoding +/// and strands every live market, so a failure here must never be "fixed" by +/// editing the vectors: add a new struct or an end-appended enum variant +/// instead (see the append-only note on `DataKey`). The six `u64`-payload +/// key vectors (`key_market`, `key_band`, `key_kind`, `key_cat_market`, +/// `key_scalar`, `key_fee_schedule`) are pairwise distinct, which is what +/// proves no variant moved when the categorical, scalar and fee-schedule +/// keys were appended. +#[cfg(test)] +mod golden { + use super::*; + use soroban_sdk::xdr::ToXdr; + use std::{format, string::String as StdString}; + + const G1: &str = "GBAKBHXK7SV7C3HGY3DDYB4QWUEGL52SEF6OO77LAD672JVXD4QCYJLF"; + const G2: &str = "GCKBJQ6633JFYSFV6HHF2FQUN4QUX5PVE63PODYIKO2CE3RCXAF5NZCW"; + + fn hex_of(env: &Env, xdr: &soroban_sdk::Bytes) -> StdString { + let hash: BytesN<32> = env.crypto().sha256(xdr).into(); + hash.to_array().iter().map(|b| format!("{b:02x}")).collect() + } + + fn fixed_market(env: &Env) -> Market { + Market { + creator: Address::from_str(env, G1), + question_hash: BytesN::from_array(env, &[1u8; 32]), + resolution_hash: BytesN::from_array(env, &[2u8; 32]), + meta_uri: String::from_str(env, "ipfs://golden"), + category: Category::Crypto, + close_time: 1_700_000_000, + fee_bps: 100, + status: MarketStatus::Resolved(Outcome::Yes), + reserve_yes: 1_234_567, + reserve_no: 7_654_321, + sets_minted: 8_888_888, + fees_accrued: 1_000, + lp_claimed: false, + } + } + + fn fixed_cat_market(env: &Env) -> CatMarket { + CatMarket { + creator: Address::from_str(env, G2), + question_hash: BytesN::from_array(env, &[1u8; 32]), + resolution_hash: BytesN::from_array(env, &[2u8; 32]), + meta_uri: String::from_str(env, "ipfs://golden"), + category: Category::Sports, + close_time: 1_700_000_000, + fee_bps: 100, + status: CatStatus::Resolved(2), + reserves: Vec::from_slice(env, &[11i128, 22, 33]), + sets_minted: 44, + fees_accrued: 5, + lp_claimed: true, + } + } + + #[test] + fn storage_encoding_matches_golden_vectors() { + let env = Env::default(); + let vectors: &[(&str, soroban_sdk::Bytes, &str)] = &[ + ( + "market", + fixed_market(&env).to_xdr(&env), + "36188085f458f8786a726cbb5e0e08ef6904e4374fbbbf3e7de03907820b0339", + ), + ( + "cat_market", + fixed_cat_market(&env).to_xdr(&env), + "27a6f739035f3c381e5059fcd136bbae2900664b9c5ba0f20ea1bf93aa3bccf9", + ), + ( + "position", + Position { yes: 5, no: 6 }.to_xdr(&env), + "40a402f6a3cc8da11230556272326a297af445d24981444ee3155354096735c0", + ), + ( + "cat_position", + CatPosition { + balances: Vec::from_slice(&env, &[7i128, 8, 9]), + } + .to_xdr(&env), + "7a72eca94d0dd95da8d6af1c9403d91751010a1a80d555ed82893816aa8f26eb", + ), + ( + "fee_terms", + FeeTerms { + recipient: Address::from_str(&env, G1), + protocol_share_bps: 5_000, + } + .to_xdr(&env), + "dd4e5aff38dae3986b90290f57c5e6604b603b4be9466aa078d7a4ab1e6fc6d2", + ), + ( + "status_open", + MarketStatus::Open.to_xdr(&env), + "f5bb24256f8d569f3e445b6fdb70770cb4e3c5a7b153bcb99bdeec43d7d15a32", + ), + ( + "status_resolved_yes", + MarketStatus::Resolved(Outcome::Yes).to_xdr(&env), + "2b321394eba29f0c5f18fd2a221d68da481a61805ccd42b5ab26689fb66e7a05", + ), + ( + "status_resolved_no", + MarketStatus::Resolved(Outcome::No).to_xdr(&env), + "bc79cb5737cf6e7a62d6cea95f96197f02826e3abfe1de0b36d8c26240d09d5e", + ), + ( + "status_void", + MarketStatus::Void.to_xdr(&env), + "84fb1b711f446432537ce5420b5b68f7e7fcc259b43b4fd1cd33ba2cfa3bc847", + ), + ( + "status_resolved_scalar", + MarketStatus::ResolvedScalar(6_250).to_xdr(&env), + "4f228882af5807e96236df11e60796ad737ead4e5a8e18a3d72a29a1a1500321", + ), + ( + "cat_open", + CatStatus::Open.to_xdr(&env), + "f5bb24256f8d569f3e445b6fdb70770cb4e3c5a7b153bcb99bdeec43d7d15a32", + ), + ( + "cat_resolved", + CatStatus::Resolved(2).to_xdr(&env), + "1affbe5b375f59f3eb26c012a92d4694d50ca88b55d070c193a60b891ba6f003", + ), + ( + "cat_void", + CatStatus::Void.to_xdr(&env), + "84fb1b711f446432537ce5420b5b68f7e7fcc259b43b4fd1cd33ba2cfa3bc847", + ), + ( + "kind_binary", + MarketKind::Binary.to_xdr(&env), + "2c4dbaa6a4d48e791b13a8ace9144759ebc4eccaed186c20ab8eb644d152c147", + ), + ( + "kind_categorical", + MarketKind::Categorical(8).to_xdr(&env), + "f36c32da22305b016f5397af49f91f8afe624c3c604b9007194c2d150c97c8e1", + ), + ( + "key_admin", + DataKey::Admin.to_xdr(&env), + "c551fd4e3b592f8f79eb0e6d45db3ef38efaff551e0f97ad2f8179a51ce1706e", + ), + ( + "key_collateral", + DataKey::Collateral.to_xdr(&env), + "be058f1cb09249de00e6f9c9317c9eb05695901ac133b690a633c3ccc8d0e746", + ), + ( + "key_treasury", + DataKey::Treasury.to_xdr(&env), + "2c3c4d715ae64eb2118c999c11d775074ef37164e99c5886f14066734b268079", + ), + ( + "key_resolver", + DataKey::Resolver.to_xdr(&env), + "220e075fa80aa713117ff5c970b113b23fe3101492c7f615a9199f0d009662c1", + ), + ( + "key_default_fee_bps", + DataKey::DefaultFeeBps.to_xdr(&env), + "5757b4fac1b45e48d21f51c49f2001d79cfa537e4e66c1c20ba177226abf96ac", + ), + ( + "key_protocol_fee_share_bps", + DataKey::ProtocolFeeShareBps.to_xdr(&env), + "99f2c34332a3a712c42188a99cdcc27e2b4df902520bd82958436f5ad2eb00b1", + ), + ( + "key_default_breaker", + DataKey::DefaultBreaker.to_xdr(&env), + "27a5c8f3ec0b32815e01af29c993d0e81434a9c956b1862ce60a37775577a442", + ), + ( + "key_paused", + DataKey::Paused.to_xdr(&env), + "a2c6f2f4702c89a770af96e0f55eea0899b67fa595bf8791c1c804a30ff930ac", + ), + ( + "key_next_id", + DataKey::NextId.to_xdr(&env), + "79dcd4a0e0d838ee8fd46ded92313aaf4bed264c9aa239efd693bae70330aa6f", + ), + ( + "key_market", + DataKey::Market(7).to_xdr(&env), + "9468d585aeb83df47ea506d9f44a1b86af6b3c46f85b5e876d9aaf5ac683ba3d", + ), + ( + "key_position", + DataKey::Position(7, Address::from_str(&env, G1)).to_xdr(&env), + "389be98ced1e5bda6c6fccc2fab7e688a757770474dd47023728dbe9abccaf99", + ), + ( + "key_fee_terms", + DataKey::FeeTerms(7).to_xdr(&env), + "49d98204299a993c50e4e168d913a24bba1082e97df589c97523ad3438d52564", + ), + ( + "key_lp_fees", + DataKey::LpFees(7).to_xdr(&env), + "382ddac8a43bcca866ff597742e3837f7361df84a7f37f11666d0713cb404761", + ), + ( + "key_breaker", + DataKey::Breaker(7).to_xdr(&env), + "cf71f552b57ae9706bed63643a832bb463163267063da6472d351f5f759fcc11", + ), + ( + "key_band", + DataKey::Band(7).to_xdr(&env), + "0cfc353eaf950432b76cc69f5c4b3c81125a9c027a736c8194839c3cfb37e04e", + ), + ( + "key_kind", + DataKey::Kind(7).to_xdr(&env), + "e024376fceb4ca0a8d2f4e24564361d9ede75ad0de688939ea36b23b4868ad35", + ), + ( + "key_cat_market", + DataKey::CatMarket(7).to_xdr(&env), + "ecc36fe74d9bb69ab421b662dc27d95863838a5c75718b11e696a1213d888a79", + ), + ( + "key_cat_position", + DataKey::CatPosition(7, Address::from_str(&env, G1)).to_xdr(&env), + "abe25f5135bf271f295ab53dea568e9ed8733b7b2e95d92daead139b3fdb6f83", + ), + ( + "key_scalar", + DataKey::Scalar(7).to_xdr(&env), + "c37848092184e76aa7e4f3ef72f6255068d91b57286a1f63bef9be9007620234", + ), + ( + "scalar_meta", + ScalarMeta { + lower: 100_000_000, + upper: 250_000_000, + } + .to_xdr(&env), + "0c8fa296ba44740c03754de99e5ce49eca58515891147d50ab2cbac7a6da98d0", + ), + ( + "key_fee_schedule", + DataKey::FeeSchedule(7).to_xdr(&env), + "28ad21a72d90132aa173d68e3b5fb58b2c75df13ae78e40ad87f0001b16288ad", + ), + ( + "key_default_fee_schedule", + DataKey::DefaultFeeSchedule.to_xdr(&env), + "50c22f14b3c7648ecff9a9a8a5d98a91c2dc2b833fb685790288c59689c2d220", + ), + ( + "fee_schedule", + FeeSchedule { + base_bps: 100, + max_bps: 1_000, + ramp_secs: 86_400, + skew_mult_bps: 500, + } + .to_xdr(&env), + "11b04b09c719862e0a9f2b8f9689c38c302f4ac02d7f9d92ab84e14f99c1a608", + ), + ]; + for (label, xdr, expected) in vectors { + assert_eq!(&hex_of(&env, xdr), expected, "{label} encoding moved"); + } + // identical tag+payload pairs legitimately hash the same; the six + // u64-payload keys above must all differ, proving no tag shifted + let u64_keys = [ + hex_of(&env, &DataKey::Market(7).to_xdr(&env)), + hex_of(&env, &DataKey::Band(7).to_xdr(&env)), + hex_of(&env, &DataKey::Kind(7).to_xdr(&env)), + hex_of(&env, &DataKey::CatMarket(7).to_xdr(&env)), + hex_of(&env, &DataKey::Scalar(7).to_xdr(&env)), + hex_of(&env, &DataKey::FeeSchedule(7).to_xdr(&env)), + ]; + for i in 0..u64_keys.len() { + for j in (i + 1)..u64_keys.len() { + assert_ne!(u64_keys[i], u64_keys[j], "DataKey tags collided"); + } + } + } } diff --git a/oracledesk-stellar/contracts/market-core/src/math.rs b/oracledesk-stellar/contracts/market-core/src/math.rs index 93e44c3..8bf222e 100644 --- a/oracledesk-stellar/contracts/market-core/src/math.rs +++ b/oracledesk-stellar/contracts/market-core/src/math.rs @@ -17,6 +17,13 @@ pub const BPS: i128 = 10_000; pub const MAX_SETS: i128 = 1_000_000_000_000_000; // 1e15 = 100M USDC (7 dp) pub const MIN_PROB_BPS: u32 = 100; // 1% pub const MAX_PROB_BPS: u32 = 9_900; // 99% +/// Longest dynamic-fee ramp: 30 days. Caps how far before close an admin +/// can start the time component. +pub const MAX_RAMP_SECS: u64 = 30 * 86_400; +/// Largest skew multiplier: 1_000 means the skew term grows 1 bp per +/// 1_000 bps (10 points) of price distance from 50%, up to 5x the +/// multiplier at the extreme prices. +pub const MAX_SKEW_MULT_BPS: u32 = 1_000; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum MathError { @@ -30,6 +37,10 @@ pub enum Settlement { Yes, No, Void, + /// Scalar market: LONG pays `floor(yes * p / BPS)`, SHORT pays + /// `floor(no * (BPS - p) / BPS)`, `p` in bps in `0..=BPS`. + /// `Scalar(BPS)` is equivalent to `Yes`, `Scalar(0)` to `No`. + Scalar(u32), } #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -111,6 +122,76 @@ pub fn price_after( } } +/// Pure mirror of the contract's `FeeSchedule` (see `lib.rs`), phrased with +/// plain numbers so `model/fpmm_model.py` and `agents/core/fpmm.ts` can +/// reproduce `effective_fee_bps` bit-for-bit. Field order mirrors the XDR. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct FeeScheduleM { + pub base_bps: u32, + pub max_bps: u32, + pub ramp_secs: u64, + pub skew_mult_bps: u32, +} + +/// The fee rate a trade pays under `s` at time `now` with the pool at +/// `(r_yes, r_no)`: `min(max, base + t + skew)` where +/// +/// - `t` ramps linearly from 0 to `max - base` over the last `ramp_secs` +/// before `close` (0 when `ramp_secs == 0` or the window has not opened; +/// `max - base` once `now >= close`), and +/// - `skew = |p - 5_000| * skew_mult_bps / 1_000` with `p` the floored +/// pre-trade YES price — so a balanced pool pays `base` and a one-sided +/// pool pays up to `base + 5 * skew_mult_bps`. +/// +/// Both floors round **down**, i.e. towards the trader: unlike the pool +/// math (`calc_buy`/`calc_sell` always round for the pool) the fee only +/// needs to be deterministic across the contract, the Python model and the +/// TS mirror, and a sub-bp gift to the trader cannot break the invariant. +/// The pre-trade price keeps the fee independent of the trade being priced +/// (a post-trade price would be circular with `calc_buy`). +/// +/// Guarantees `base <= result <= max` for every schedule with +/// `base <= max < BPS`; any other schedule is rejected. `now >= close` +/// (possible in quotes) clamps to the ramp end. All arithmetic is `i128`, +/// so no input pair can overflow. +pub fn effective_fee_bps( + s: &FeeScheduleM, + now: u64, + close: u64, + r_yes: i128, + r_no: i128, +) -> Result { + if s.base_bps > s.max_bps || s.max_bps as i128 >= BPS { + return Err(MathError::InvalidInput); + } + let base = s.base_bps as i128; + let max = s.max_bps as i128; + let delta = max - base; + let t = if s.ramp_secs == 0 { + 0 + } else { + let ramp = s.ramp_secs as i128; + let into_ramp = if now >= close { + ramp + } else { + s.ramp_secs.saturating_sub(close - now) as i128 + }; + mul_div_floor(into_ramp, delta, ramp)? + }; + let p = price_yes_bps(r_yes, r_no)?; + let d = (p - 5_000).abs(); + let skew = mul_div_floor(d, s.skew_mult_bps as i128, 1_000)?; + let total = base + .checked_add(t) + .and_then(|v| v.checked_add(skew)) + .ok_or(MathError::Overflow)?; + Ok(if total > max { + max as u32 + } else { + total as u32 + }) +} + /// Pool opens at `yes_bps` probability with `seed` complete sets minted. /// The pool keeps fewer of the *likelier* outcome; the creator keeps the rest. pub fn seed_reserves(seed: i128, yes_bps: u32) -> Result { @@ -260,9 +341,25 @@ pub fn settle_value(kind: Settlement, yes: i128, no: i128) -> i128 { Settlement::Yes => yes, Settlement::No => no, Settlement::Void => (yes + no) / 2, + Settlement::Scalar(p) => scalar_payout(p, yes, no), } } +/// Collateral value of a (yes, no) share pair in a scalar market whose +/// resolved value maps to `payout_long_bps` LONG per complete set. Both legs +/// floor independently, so no holder ever receives more than their exact +/// rational share, and since `Σyes == Σno == sets_minted` across holders and +/// the pool the total paid out never exceeds `sets_minted`. +/// +/// `payout_long_bps` must be `<= BPS`; the market layer validates that in +/// `resolve_scalar` before any payout can be settled. +pub fn scalar_payout(payout_long_bps: u32, yes: i128, no: i128) -> i128 { + let p = payout_long_bps as i128; + let long = yes * p / BPS; + let short = no * (BPS - p) / BPS; + long + short +} + #[cfg(test)] mod tests { use super::*; @@ -326,6 +423,73 @@ mod tests { assert!(settle_value(Settlement::Void, 5, 5) <= 5); } + #[test] + fn scalar_extremes_match_binary() { + for (yes, no) in [(0i128, 7i128), (3, 4), (1_000_000, 250_000)] { + assert_eq!( + settle_value(Settlement::Scalar(BPS as u32), yes, no), + settle_value(Settlement::Yes, yes, no) + ); + assert_eq!( + settle_value(Settlement::Scalar(0), yes, no), + settle_value(Settlement::No, yes, no) + ); + } + } + + #[test] + fn scalar_rounding_favours_pool() { + let mut x: u64 = 0x0DDC_0FFE_E0DD_F00D; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let yes = next() % 1_000_000_000_000; + let no = next() % 1_000_000_000_000; + let p = (next() % (BPS + 1)) as u32; + let paid = scalar_payout(p, yes, no); + // floor + floor never exceeds the exact rational amount + assert!(paid * BPS <= yes * p as i128 + no * (BPS - p as i128)); + } + } + + #[test] + fn scalar_payout_never_exceeds_sets() { + let mut x: u64 = 0x5EED_5EED_5EED_5EED; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (x >> 33) as i128 + }; + for _ in 0..2_000 { + let sets = 1 + next() % 1_000_000_000_000; + let p = (next() % (BPS + 1)) as u32; + // three holders split `sets` complete sets independently per leg + let (mut rest_y, mut rest_n) = (sets, sets); + let mut total: i128 = 0; + for h in 0..3 { + let take_y = if h == 2 { + rest_y + } else { + next() % (rest_y + 1) + }; + let take_n = if h == 2 { + rest_n + } else { + next() % (rest_n + 1) + }; + rest_y -= take_y; + rest_n -= take_n; + total += scalar_payout(p, take_y, take_n); + } + assert!(total <= sets, "scalar overpaid: {total} > {sets} at p={p}"); + } + } + #[test] fn split_fee_sums_exactly() { let mut x: u64 = 0x9E37_79B9_7F4A_7C15; @@ -388,4 +552,155 @@ mod tests { price_yes_bps(1_100, 910).unwrap() ); } + + fn sched(base_bps: u32, max_bps: u32, ramp_secs: u64, skew_mult_bps: u32) -> FeeScheduleM { + FeeScheduleM { + base_bps, + max_bps, + ramp_secs, + skew_mult_bps, + } + } + + #[test] + fn effective_fee_within_bounds() { + let mut x: u64 = 0xFEE1_0BEE_FEE1_0BEE; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + x >> 33 + }; + for _ in 0..2_000 { + let base = (next() % 1_001) as u32; + let max = base + (next() % (1_001 - base as u64)) as u32; + let s = sched( + base, + max, + next() % (MAX_RAMP_SECS + 1), + (next() % (MAX_SKEW_MULT_BPS as u64 + 1)) as u32, + ); + let close = 1_000_000 + next() % 1_000_000_000; + let now = next(); + let r_yes = 1 + (next() % 1_000_000_000_000) as i128; + let r_no = 1 + (next() % 1_000_000_000_000) as i128; + let fee = effective_fee_bps(&s, now, close, r_yes, r_no).unwrap(); + assert!(fee >= base, "fee {fee} < base {base}"); + assert!(fee <= max, "fee {fee} > max {max}"); + assert!(max <= 1_000); + } + } + + #[test] + fn effective_fee_ramp_monotonic_in_time() { + let s = sched(100, 800, 10_000, 250); + let (close, r_yes, r_no) = (500_000u64, 1_000i128, 3_000i128); + let mut prev = 0u32; + let mut t = close - 20_000; + while t <= close + 5_000 { + let fee = effective_fee_bps(&s, t, close, r_yes, r_no).unwrap(); + assert!(fee >= prev, "fee fell from {prev} to {fee} at t={t}"); + prev = fee; + t += 137; + } + assert_eq!(prev, 800, "past close the fee clamps to max"); + } + + #[test] + fn effective_fee_symmetric_in_skew() { + let s = sched(200, 1_000, 0, 100); + // exactly representable ratios: p and 10_000 - p both hold, so the + // floored distance d is identical on either side and the fee must + // be exactly equal + for (a, b) in [ + (1_000i128, 3_000i128), + (900, 1_100), + (5_000, 5_000), + (1, 4), + (7, 3), + (1, 1), + ] { + assert_eq!( + effective_fee_bps(&s, 0, 1, a, b).unwrap(), + effective_fee_bps(&s, 0, 1, b, a).unwrap(), + "asymmetric at {a}/{b}" + ); + } + // inexact ratios can shift the floored price distance by 1, which + // the floored scaling bounds by one bp while skew_mult <= 1_000 + let mut x: u64 = 0x5CE1_5CE1_5CE1_5CE1; + let mut next = move || { + x = x + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + x >> 33 + }; + for _ in 0..2_000 { + let a = 1 + (next() % 1_000_000_000_000) as i128; + let b = 1 + (next() % 1_000_000_000_000) as i128; + let f1 = effective_fee_bps(&s, 0, 1, a, b).unwrap(); + let f2 = effective_fee_bps(&s, 0, 1, b, a).unwrap(); + assert!((f1 as i64 - f2 as i64).abs() <= 1, "{a}/{b}: {f1} vs {f2}"); + } + } + + #[test] + fn effective_fee_zero_ramp() { + let s = sched(120, 900, 0, 300); + let far = effective_fee_bps(&s, 0, u64::MAX, 1_000, 1_000).unwrap(); + let at_close = effective_fee_bps(&s, 5_000_000, 5_000_000, 1_000, 1_000).unwrap(); + let past = effective_fee_bps(&s, 6_000_000, 5_000_000, 1_000, 1_000).unwrap(); + assert_eq!(far, 120); + assert_eq!(far, at_close); + assert_eq!(far, past); + } + + #[test] + fn effective_fee_rejects_unbounded_schedule() { + assert_eq!( + effective_fee_bps(&sched(500, 400, 0, 0), 0, 1, 1, 1), + Err(MathError::InvalidInput) + ); + // max must stay strictly below BPS (calc_sell requires fee < BPS) + assert_eq!( + effective_fee_bps(&sched(0, 10_000, 0, 0), 0, 1, 1, 1), + Err(MathError::InvalidInput) + ); + } + + #[test] + fn effective_fee_matches_fixtures() { + // Shared cross-language table (also read by model/fpmm_model.py and + // agents/core/fpmm.ts): one row per fixture, nine numbers — + // [base_bps, max_bps, ramp_secs, skew_mult_bps, + // now, close, r_yes, r_no, expected_fee_bps] + let raw = include_str!("../../../model/fee_fixtures.json"); + let mut nums = [0i128; 512]; + let mut n = 0usize; + let mut cur: Option = None; + for b in raw.bytes() { + if b.is_ascii_digit() { + let d = (b - b'0') as i128; + cur = Some(cur.map_or(d, |v| v * 10 + d)); + } else if let Some(v) = cur.take() { + assert!(n < 512, "fee_fixtures.json holds too many numbers"); + nums[n] = v; + n += 1; + } + } + if let Some(v) = cur.take() { + assert!(n < 512, "fee_fixtures.json holds too many numbers"); + nums[n] = v; + n += 1; + } + assert!( + n > 0 && n.is_multiple_of(9), + "expected 9 numbers per row, got {n}" + ); + for row in nums[..n].chunks(9) { + let s = sched(row[0] as u32, row[1] as u32, row[2] as u64, row[3] as u32); + let fee = effective_fee_bps(&s, row[4] as u64, row[5] as u64, row[6], row[7]).unwrap(); + assert_eq!(fee, row[8] as u32, "fixture {row:?}"); + } + } } diff --git a/oracledesk-stellar/contracts/market-core/src/math_categorical.rs b/oracledesk-stellar/contracts/market-core/src/math_categorical.rs new file mode 100644 index 0000000..4b87987 --- /dev/null +++ b/oracledesk-stellar/contracts/market-core/src/math_categorical.rs @@ -0,0 +1,605 @@ +//! Pure integer math for a categorical N-outcome fixed-product market maker +//! (FPMM): the generalisation of `math.rs` from 2 outcomes to `n` in +//! `2..=MAX_MARKET_OUTCOMES`. +//! +//! No Soroban types in here (no `Env`, no `alloc`), so `cargo test` exercises +//! it natively and `model/fpmm_model.py` / `agents/core/fpmm.ts` mirror it +//! for property testing. +//! +//! Conventions +//! - Same units and pool-favouring rounding as `math.rs`: everything that can +//! round rounds so the pool never loses. +//! - The binary invariant `k = r_yes * r_no` generalises to the product of +//! all reserves, `k = r_0 * ... * r_{n-1}`. Buys and sells never decrease +//! `k`, which is what guarantees no sequence of trades can drain the pool. +//! - Reserves arrive as a slice of length `n` and results are fixed-size +//! arrays whose first `n` entries are meaningful, so nothing here needs a +//! heap. +//! - Every multiplication is `checked_*`. A reachable but extreme pool state +//! (n = 8 with reserves near `MAX_SETS`, drained almost to zero by a huge +//! sell) can exceed `i128` in the iterative loops; such a trade fails with +//! `Overflow` rather than wrapping. + +use crate::math::{mul_div_ceil, mul_div_floor, MathError, BPS, MIN_PROB_BPS}; + +/// Maximum number of outcomes a categorical market can have. Bounds every +/// loop here, so a trade's cost is linear in the outcome count at most. +pub const MAX_MARKET_OUTCOMES: u32 = 8; +const MAX_N: usize = MAX_MARKET_OUTCOMES as usize; + +/// Reciprocal scale for `prices_bps`: `rec_i = PRICE_SCALE / r_i`. Chosen so +/// `rec_i * n` and `BPS * rec_i` stay far inside `i128` for every reachable +/// reserve (`r_i <= MAX_SETS = 1e15`), keeping the reciprocals' relative +/// rounding below ~1e-9 for any realistic pool while prices stay exact at +/// the ratios that matter. +const PRICE_SCALE: i128 = 1_000_000_000_000_000_000; // 1e18 + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CatBuyQuote { + pub shares_out: i128, + pub fee: i128, + /// Collateral that actually mints complete sets (`collateral_in - fee`). + pub net: i128, + /// Reserves after the trade; the first `n` entries are meaningful. + pub new_reserves: [i128; MAX_N], +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CatSellQuote { + pub shares_in: i128, + pub fee: i128, + /// Complete sets burned from the pool (`collateral_out + fee`). + pub gross: i128, + /// Reserves after the trade; the first `n` entries are meaningful. + pub new_reserves: [i128; MAX_N], +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CatSeed { + /// Reserves after seeding; the first `n` entries are meaningful. + pub reserves: [i128; MAX_N], + /// Outcome shares handed back to the creator (per outcome) so the pool + /// opens at the requested probabilities. `reserves[i] + creator_shares[i] + /// == seed` for every i, mirroring `SeedReserves` at n = 2. + pub creator_shares: [i128; MAX_N], +} + +fn check_reserves(reserves: &[i128]) -> Result { + let n = reserves.len(); + if !(2..=MAX_N).contains(&n) { + return Err(MathError::InvalidInput); + } + if reserves.iter().any(|r| *r <= 0) { + return Err(MathError::InvalidInput); + } + Ok(n) +} + +fn check_index(n: usize, outcome_index: u32) -> Result { + let j = outcome_index as usize; + if j >= n { + return Err(MathError::InvalidInput); + } + Ok(j) +} + +/// Buy `collateral_in` worth of outcome `outcome_index`. +/// +/// 1. fee = ceil(in * fee_bps / BPS); net = in - fee +/// 2. mint `net` complete sets: r_i' = r_i + net for every i != j +/// 3. the pool keeps r_j' = r_j * Π_{i≠j} r_i / (r_i + net) (k preserved) +/// 4. the buyer receives r_j + net - r_j' +/// +/// Step 3 is evaluated incrementally: `acc = r_j`, then for each i != j, +/// `acc = ceil(acc * r_i / (r_i + net))`. +/// +/// Rounding proof. Write E_k for the exact real product after k factors and +/// q = r_i / (r_i + net) < 1 for the next one. Ceil is never below its +/// argument and multiplying by q preserves `acc >= E`, so the computed +/// reserve never drops below the ideal E. For the upper side, if +/// `acc <= E + d` then +/// `ceil(acc * q) <= acc * q + 1 <= (E + d) * q + 1 = E' + d*q + 1`, +/// and `d * q < d` because q < 1 and d >= 0: the ceil adds back at most 1 +/// while the accumulated deviation shrinks. Starting from `acc = r_j = E_0` +/// (d = 0), after the n-1 steps `E <= r_j' <= E + (n-1)`. The pool therefore +/// keeps between 0 and n-1 units more than exact — the buyer can lose the +/// rounding but never gain it. +pub fn calc_buy( + reserves: &[i128], + outcome_index: u32, + collateral_in: i128, + fee_bps: u32, +) -> Result { + let n = check_reserves(reserves)?; + let j = check_index(n, outcome_index)?; + if collateral_in <= 0 { + return Err(MathError::InvalidInput); + } + let fee = mul_div_ceil(collateral_in, fee_bps as i128, BPS)?; + let net = collateral_in - fee; + if net <= 0 { + return Err(MathError::InvalidInput); + } + + let mut new_reserves = [0i128; MAX_N]; + let mut acc = reserves[j]; + for i in 0..n { + if i == j { + continue; + } + let new_other = reserves[i].checked_add(net).ok_or(MathError::Overflow)?; + new_reserves[i] = new_other; + acc = mul_div_ceil(acc, reserves[i], new_other)?; + } + new_reserves[j] = acc; + let shares_out = reserves[j].checked_add(net).ok_or(MathError::Overflow)? - acc; + if shares_out <= 0 { + return Err(MathError::InvalidInput); + } + Ok(CatBuyQuote { + shares_out, + fee, + net, + new_reserves, + }) +} + +/// Sell outcome `outcome_index` for an exact `collateral_out` +/// (Gnosis-style, so no square root on-chain). +/// +/// 1. gross = ceil(out * BPS / (BPS - fee_bps)); fee = gross - out +/// 2. burn `gross` complete sets: r_i' = r_i - gross for every i != j +/// 3. the pool must end at r_j' = r_j * Π_{i≠j} r_i / (r_i - gross) +/// 4. the seller gives r_j' + gross - r_j shares +/// +/// Step 3 uses the same ceil-iterated accumulation as `calc_buy` with factor +/// `r_i / (r_i - gross) > 1`, so by the same argument `r_j'` is between the +/// ideal value and ideal + (n-1): the seller pays a rounding margin, never +/// the pool. Every other reserve must exceed `gross`, so no reserve can +/// reach zero and the product stays positive. +pub fn calc_sell( + reserves: &[i128], + outcome_index: u32, + collateral_out: i128, + fee_bps: u32, +) -> Result { + let n = check_reserves(reserves)?; + let j = check_index(n, outcome_index)?; + if collateral_out <= 0 || (fee_bps as i128) >= BPS { + return Err(MathError::InvalidInput); + } + let gross = mul_div_ceil(collateral_out, BPS, BPS - fee_bps as i128)?; + let fee = gross - collateral_out; + + let mut new_reserves = [0i128; MAX_N]; + let mut acc = reserves[j]; + for i in 0..n { + if i == j { + continue; + } + if reserves[i] <= gross { + return Err(MathError::InsufficientLiquidity); + } + let new_other = reserves[i] - gross; + new_reserves[i] = new_other; + acc = mul_div_ceil(acc, reserves[i], new_other)?; + } + new_reserves[j] = acc; + let shares_in = acc.checked_add(gross).ok_or(MathError::Overflow)? - reserves[j]; + if shares_in <= 0 { + return Err(MathError::InvalidInput); + } + Ok(CatSellQuote { + shares_in, + fee, + gross, + new_reserves, + }) +} + +/// Spot price of every outcome in bps. The categorical price relation +/// `p_j = (1/r_j) / Σ_i (1/r_i)` (the binary formula `r_no / (r_yes + r_no)` +/// generalised) is evaluated on integer reciprocals so no division chain can +/// overflow: `rec_i = PRICE_SCALE / r_i` (floor), then +/// `p_j = floor(BPS * rec_j / Σ rec)`. +/// +/// Every floor discards a fraction, so each price is at most its exact value +/// and the n prices sum to at most BPS; each floor loses less than 1, so the +/// sum is at least `BPS - n`. Clients should treat the prices as display +/// fractions (they never enter trade math) and tolerate that slack. +pub fn prices_bps(reserves: &[i128]) -> Result<[u32; MAX_N], MathError> { + let n = check_reserves(reserves)?; + let mut recs = [0i128; MAX_N]; + let mut sum: i128 = 0; + for i in 0..n { + recs[i] = PRICE_SCALE / reserves[i]; + sum = sum.checked_add(recs[i]).ok_or(MathError::Overflow)?; + } + if sum <= 0 { + return Err(MathError::InsufficientLiquidity); + } + let mut prices = [0u32; MAX_N]; + for i in 0..n { + prices[i] = mul_div_floor(BPS, recs[i], sum)? as u32; + } + Ok(prices) +} + +/// Pool opens at the requested probabilities with `seed` complete sets +/// minted. Each reserve is `seed * p_min / p_i` (floored), so reserve +/// *ratios* are `1/p_i` and the derived prices approximate `probs_bps`. +/// +/// The creator keeps `seed - r_i` shares of every outcome — only the likelier +/// outcomes have any, exactly as `math::seed_reserves` does at n = 2 — so +/// `r_i + creator_shares_i == seed` and the per-outcome holder total stays +/// `sets_minted` from the first trade. +/// +/// Validation: `2 <= n <= MAX_MARKET_OUTCOMES`, `seed > 0`, every probability +/// in `MIN_PROB_BPS..=BPS` (each individual check) and the probabilities +/// summing to exactly BPS. +pub fn seed_reserves(seed: i128, probs_bps: &[u32]) -> Result { + let n = probs_bps.len(); + if !(2..=MAX_N).contains(&n) || seed <= 0 { + return Err(MathError::InvalidInput); + } + let mut sum: u32 = 0; + let mut p_min = u32::MAX; + for p in probs_bps { + if *p < MIN_PROB_BPS { + return Err(MathError::InvalidInput); + } + sum = sum.checked_add(*p).ok_or(MathError::Overflow)?; + p_min = p_min.min(*p); + } + if sum != BPS as u32 { + return Err(MathError::InvalidInput); + } + + let mut reserves = [0i128; MAX_N]; + let mut creator_shares = [0i128; MAX_N]; + for (i, p) in probs_bps.iter().enumerate() { + let r = mul_div_floor(seed, p_min as i128, *p as i128)?; + if r <= 0 { + return Err(MathError::InvalidInput); + } + reserves[i] = r; + creator_shares[i] = seed - r; + } + Ok(CatSeed { + reserves, + creator_shares, + }) +} + +/// Collateral value of one holder's per-outcome balances once final. +/// `Some(w)` pays the winning balance; `None` (void) pays +/// `floor(Σ balances / n)`, the N-way generalisation of the binary void's +/// `(yes + no) / 2`. +/// +/// Every complete set is worth exactly 1 and every rounding is a floor, so +/// the total paid out across all holders never exceeds `sets_minted`. +pub fn settle_value(resolved: Option, balances: &[i128]) -> Result { + let n = balances.len(); + if !(2..=MAX_N).contains(&n) { + return Err(MathError::InvalidInput); + } + match resolved { + Some(w) => { + let j = check_index(n, w)?; + Ok(balances[j]) + } + None => { + let mut sum: i128 = 0; + for b in balances { + if *b < 0 { + return Err(MathError::InvalidInput); + } + sum = sum.checked_add(*b).ok_or(MathError::Overflow)?; + } + Ok(sum / n as i128) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::calc_buy as binary_calc_buy; + + struct Lcg(u64); + + impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 33 + } + } + + /// Π reserves, the categorical pool invariant. Test-sized reserves keep + /// this inside i128 (production uses checked math instead). + fn k(reserves: &[i128], n: usize) -> i128 { + let mut acc: i128 = 1; + for r in &reserves[..n] { + acc = acc.checked_mul(*r).unwrap(); + } + acc + } + + #[test] + fn cat_buy_and_sell_examples_match_model() { + // 1000/1000/1000 pool, buy 100 of outcome 0 with no fee: + // acc = ceil(1000*1000/1100) = 910; ceil(910*1000/1100) = 828 + // shares = 1000 + 100 - 828 = 272; reserves [828, 1100, 1100] + let q = calc_buy(&[1_000, 1_000, 1_000], 0, 100, 0).unwrap(); + assert_eq!(q.shares_out, 272); + assert_eq!(q.new_reserves[..3], [828, 1_100, 1_100]); + + // sell 100 out of the same pool: + // 1000 -> ceil(1000*1000/900) = 1112 -> ceil(1112*1000/900) = 1236 + // shares = 1236 + 100 - 1000 = 336; reserves [1236, 900, 900] + let s = calc_sell(&[1_000, 1_000, 1_000], 0, 100, 0).unwrap(); + assert_eq!(s.shares_in, 336); + assert_eq!(s.new_reserves[..3], [1_236, 900, 900]); + } + + #[test] + fn cat_buy_k_never_decreases() { + let mut rng = Lcg(0x9E37_79B9_7F4A_7C15); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; // 2..=8 + let mut reserves = [0i128; MAX_N]; + for r in reserves.iter_mut().take(n) { + *r = 100 + (rng.next() % 10_000) as i128; + } + let j = (rng.next() % n as u64) as u32; + let amt = 100 + (rng.next() % 100) as i128; + let fee = (rng.next() % 300) as u32; + + let k0 = k(&reserves, n); + let q = calc_buy(&reserves[..n], j, amt, fee).unwrap(); + assert!( + k(&q.new_reserves, n) >= k0, + "buy decreased k: n={n} j={j} amt={amt} fee={fee}" + ); + // the bought outcome's reserve can shrink; every other grows by net + for (i, r) in reserves.iter().enumerate().take(n) { + if i != j as usize { + assert_eq!(q.new_reserves[i], r + q.net); + } + } + } + } + + #[test] + fn cat_round_trip_never_profits() { + let mut rng = Lcg(0x0DDB_1A5E_5BAD_5EED); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; + let mut reserves = [0i128; MAX_N]; + for r in reserves.iter_mut().take(n) { + *r = 1_000 + (rng.next() % 100_000) as i128; + } + let j = (rng.next() % n as u64) as u32; + let amt = 100 + (rng.next() % 400) as i128; + let fee = (rng.next() % 300) as u32; + + let buy = calc_buy(&reserves[..n], j, amt, fee).unwrap(); + // selling the same collateral back out must cost at least the + // shares the buy paid (A -> B -> A is never a profit) + if let Ok(sell) = calc_sell(&buy.new_reserves[..n], j, amt, fee) { + assert!( + sell.shares_in >= buy.shares_out, + "free money: n={n} j={j} amt={amt} fee={fee} \ + bought={} sold={}", + buy.shares_out, + sell.shares_in + ); + } + } + } + + #[test] + fn cat_prices_sum_within_n_of_bps() { + let mut rng = Lcg(0xB1A5_E5B1_A5B1_A5B1); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; + let mut reserves = [0i128; MAX_N]; + for r in reserves.iter_mut().take(n) { + *r = 1_000 + (rng.next() % 1_000_000_000_000) as i128; + } + let prices = prices_bps(&reserves[..n]).unwrap(); + let sum: u32 = prices[..n].iter().sum(); + assert!(sum <= BPS as u32, "prices sum above BPS: n={n} sum={sum}"); + assert!( + sum + n as u32 >= BPS as u32, + "prices sum below BPS - n: n={n} sum={sum}" + ); + } + // equal reserves -> equal prices, each exactly floor(BPS / n) + let flat = [7_777i128; MAX_N]; + for n in 2..=MAX_N { + let prices = prices_bps(&flat[..n]).unwrap(); + let sum: u32 = prices[..n].iter().sum(); + assert!( + sum + n as u32 >= BPS as u32 && sum <= BPS as u32, + "n={n} sum={sum}" + ); + for p in &prices[..n] { + assert_eq!(*p, (BPS as u32) / n as u32); + } + } + } + + #[test] + fn cat_n2_matches_binary_calc_buy_within_1() { + let mut rng = Lcg(0x5EED_5EED_5EED_5EED); + for _ in 0..2_000 { + let r_bought = 1_000 + (rng.next() % 1_000_000_000) as i128; + let r_other = 1_000 + (rng.next() % 1_000_000_000) as i128; + let amt = 1 + (rng.next() % (r_bought.min(r_other) / 2) as u64) as i128; + let fee = (rng.next() % 300) as u32; + + // a 2-outcome categorical pool IS the binary FPMM + let bin = binary_calc_buy(r_bought, r_other, amt, fee).unwrap(); + let cat = calc_buy(&[r_bought, r_other], 0, amt, fee).unwrap(); + assert!( + (cat.shares_out - bin.shares_out).abs() <= 1, + "shares drift: cat={} bin={}", + cat.shares_out, + bin.shares_out + ); + assert_eq!(cat.net, bin.net); + assert_eq!(cat.fee, bin.fee); + // outcome 0 is the "bought" side, outcome 1 the "other" + assert_eq!(cat.new_reserves[0], bin.new_reserve_bought); + assert_eq!(cat.new_reserves[1], bin.new_reserve_other); + } + } + + #[test] + fn cat_void_never_overpays() { + let mut rng = Lcg(0xDEAD_BEEF_DEAD_BEEF); + for _ in 0..2_000 { + let n = 2 + (rng.next() % 7) as usize; + let sets: i128 = 1_000_000; + // three holders share each outcome's `sets` shares with the pool; + // generated column-major: held_t[outcome][holder] + let mut held_t = [[0i128; 3]; MAX_N]; + for col in held_t.iter_mut().take(n) { + let mut rest = sets; + for (h, take) in col.iter_mut().enumerate() { + *take = if h == 2 { + rest + } else { + (rng.next() % (rest as u64 + 1)) as i128 + }; + rest -= *take; + } + assert!(rest >= 0); + } + let row_of = |held_t: &[[i128; 3]; MAX_N], h: usize| -> [i128; MAX_N] { + let mut row = [0i128; MAX_N]; + for (i, col) in held_t.iter().enumerate().take(n) { + row[i] = col[h]; + } + row + }; + + // resolved(w): total paid = Σ_h bal[h][w] <= sets + let w = (rng.next() % n as u64) as u32; + let mut void_total: i128 = 0; + let mut resolved_total: i128 = 0; + for h in 0..3 { + let row = row_of(&held_t, h); + void_total += settle_value(None, &row[..n]).unwrap(); + resolved_total += settle_value(Some(w), &row[..n]).unwrap(); + } + assert!(void_total <= sets, "void overpaid: {void_total} > {sets}"); + assert!(resolved_total <= sets); + } + } + + #[test] + fn cat_seed_matches_requested_probs() { + let cases: [[u32; 8]; 4] = [ + [5000, 5000, 0, 0, 0, 0, 0, 0], + [6800, 3200, 0, 0, 0, 0, 0, 0], + [5000, 3000, 2000, 0, 0, 0, 0, 0], + [9000, 500, 400, 100, 0, 0, 0, 0], + ]; + let lens = [2usize, 2, 3, 4]; + for (probs, len) in cases.iter().zip(lens) { + let s = seed_reserves(1_000_000_000, &probs[..len]).unwrap(); + let prices = prices_bps(&s.reserves[..len]).unwrap(); + for i in 0..len { + let drift = (prices[i] as i32 - probs[i] as i32).abs(); + assert!(drift <= 2, "price drift {drift} bps at i={i}"); + // every outcome splits exactly one set: reserve + creator = seed + assert_eq!(s.reserves[i] + s.creator_shares[i], 1_000_000_000); + } + // the least likely outcome is fully held by the pool + let min_i = (0..len).min_by_key(|i| probs[*i]).unwrap(); + assert_eq!(s.creator_shares[min_i], 0); + } + // 8 equal outcomes: prices are exactly equal in total + let flat = [1250u32; 8]; + let s = seed_reserves(1_000_000_000, &flat).unwrap(); + let prices = prices_bps(&s.reserves).unwrap(); + let sum: u32 = prices.iter().sum(); + assert_eq!(sum, BPS as u32); + for (i, p) in prices.iter().enumerate() { + assert_eq!(s.reserves[i], 1_000_000_000); + assert_eq!(s.creator_shares[i], 0); + assert_eq!(*p, 1250); + } + + // validation + assert_eq!( + seed_reserves(1_000, &[6000, 3000]), + Err(MathError::InvalidInput) // sum != BPS + ); + assert_eq!( + seed_reserves(1_000, &[9950, 50]), + Err(MathError::InvalidInput) // below MIN_PROB_BPS + ); + assert_eq!( + seed_reserves(1_000, &[10_000]), + Err(MathError::InvalidInput) // n < 2 + ); + assert_eq!( + seed_reserves(1_000, &[1250; 9]), + Err(MathError::InvalidInput) // n > MAX_MARKET_OUTCOMES + ); + assert_eq!( + seed_reserves(0, &[5000, 5000]), + Err(MathError::InvalidInput) + ); + } + + #[test] + fn cat_settle_rejects_bad_shapes() { + assert_eq!( + settle_value(Some(2), &[10, 10]), + Err(MathError::InvalidInput) + ); + assert_eq!(settle_value(None, &[10]), Err(MathError::InvalidInput)); + assert_eq!(settle_value(None, &[-1, 5]), Err(MathError::InvalidInput)); + // resolved pays exactly the winning balance + assert_eq!(settle_value(Some(1), &[3, 7, 11]).unwrap(), 7); + // void floors toward the pool + assert_eq!(settle_value(None, &[3, 7, 11]).unwrap(), 7); // 21 / 3 + assert_eq!(settle_value(None, &[1, 1, 1, 1, 1, 1, 1]).unwrap(), 1); + } + + #[test] + fn cat_buy_and_sell_reject_bad_arguments() { + let r = [1_000i128, 2_000, 3_000]; + assert_eq!( + calc_buy(&r, 3, 100, 0), + Err(MathError::InvalidInput) // index out of range + ); + assert_eq!(calc_buy(&r, 0, 0, 0), Err(MathError::InvalidInput)); + assert_eq!( + calc_buy(&[1_000, 0], 0, 100, 0), + Err(MathError::InvalidInput) + ); + // fee larger than the trade leaves nothing to mint + assert_eq!( + calc_buy(&[1_000, 1_000], 0, 100, 10_000), + Err(MathError::InvalidInput) + ); + // selling more than an "other" reserve holds is illiquid + assert_eq!( + calc_sell(&[1_000, 2_000, 3_000], 0, 5_000, 0), + Err(MathError::InsufficientLiquidity) + ); + // a sell leaves the sold reserve strictly larger (shares_in > 0) + let q = calc_sell(&[1_000, 2_000, 3_000], 0, 100, 0).unwrap(); + assert!(q.shares_in > 0 && q.new_reserves[0] > 1_000); + assert_eq!(q.new_reserves[1], 1_900); + assert_eq!(q.new_reserves[2], 2_900); + } +} diff --git a/oracledesk-stellar/contracts/resolver/market_core.wasm b/oracledesk-stellar/contracts/resolver/market_core.wasm index 4e6bdb172653cdf7c3445ad225b792471d2ad601..d6b64888ebe7b116b56281b933f8168b5877fa8a 100644 GIT binary patch literal 76343 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

B!U*OGHX)3zDBb%Xdm@8`FvaaRCnkxqMM& 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, + pub threshold: u32, + pub dispute_window: u64, + pub outcome_count: u32, +} + +/// A categorical winner once `threshold` signers have attested the index. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatProposal { + pub outcome_index: u32, + pub proposed_at: u64, +} + +/// Scalar (range) counterpart of [`PriceConfig`]. The market settles on the +/// fractional position of `price` inside `[lower, upper]`: +/// `payout_long_bps = (clamped_price - lower) / (upper - lower)`, and the +/// SHORT side pays the complement. A reading below the range clamps to +/// `lower` (LONG pays 0), above to `upper` (LONG pays 1.0); a non-positive +/// reading is an invalid price, never a clamp. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScalarPriceConfig { + pub reflector: Address, + pub asset: OracleAsset, + /// Bottom of the staked range. Validated `0 <= lower < upper`, with + /// `upper` capped so `upper * BPS` cannot overflow. + pub lower: i128, + pub upper: i128, + pub max_staleness: u64, +} + /// The full resolution rule a market's `resolution_hash` commits to. Hashing /// this (rather than the bare `PriceConfig`/`SignerConfig`) keeps the two /// modes in disjoint hash spaces even if their field encodings could /// otherwise collide. See `docs/adr/0001-resolution-commitment.md`. +/// +/// Variants are append-only: the XDR tag is the variant position, so a new +/// mode goes at the end and existing commitments never move. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum ResolutionSpec { @@ -202,6 +253,11 @@ pub enum ResolutionSpec { Signers(SignerConfig), WeightedKeys(WeightedKeyConfig), SignersDisputable(DisputableSignerConfig), + /// Categorical counterpart of `Signers` (appended, tag 4). + CategoricalSigners(CatSignerConfig), + /// Scalar counterpart of `Price` (appended, tag 5): the binary pool + /// settles at a fractional LONG payout instead of a side. + Scalar(ScalarPriceConfig), } /// Domain tag prepended to a [`ResolutionSpecV2`] before hashing. Every legacy @@ -266,6 +322,8 @@ pub enum Error { TooManySignatures = 23, DisputeWindowClosed = 24, NoDispute = 25, + /// `attest_cat` outcome index not below the committed `outcome_count`. + InvalidOutcomeIndex = 26, } #[contracttype] @@ -289,6 +347,17 @@ pub enum DataKey { Dispute(u64), EscalationVote(u64, Address), EscalationCount(u64, Verdict), + // appended (new variants only ever go at the end — the XDR tag is the + // variant position) + /// Registered `CatSignerConfig` of a categorical market. + CatSigners(u64), + /// A signer's attestation of one categorical outcome index. + CatAttestation(u64, u32, Address), + /// Attestation count for (market, outcome index). + CatCount(u64, u32), + CatProposal(u64), + /// Registered `ScalarPriceConfig` of a scalar market. + Scalar(u64), } /// The registered signer committee, whichever kind the market committed to. @@ -374,6 +443,38 @@ pub struct MarketExpired { pub mode: ExpiryMode, } +/// Categorical counterpart of `ResolutionProposed`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatResolutionProposed { + #[topic] + pub market_id: u64, + pub outcome_index: u32, + pub proposed_at: u64, +} + +/// Categorical counterpart of `ResolutionFinalized`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatResolutionFinalized { + #[topic] + pub market_id: u64, + pub outcome_index: u32, + pub finalized_at: u64, +} + +/// Scalar counterpart of `PriceResolution`: the settled market carries a +/// fractional LONG payout instead of a side. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScalarPriceResolution { + #[topic] + pub market_id: u64, + pub price: i128, + pub price_timestamp: u64, + pub payout_long_bps: u32, +} + #[soroban_sdk::contractclient(name = "ReflectorPriceClient")] pub trait ReflectorPrice { fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option; @@ -429,6 +530,32 @@ impl Resolver { Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) } + /// See `register_price_spec` — same commit/reveal rule, categorical + /// outcome-index committee. Only meaningful for markets whose + /// `resolution_hash` commits to `ResolutionSpec::CategoricalSigners` with + /// this exact `outcome_count`; the reveal target's `resolution_hash` is + /// read from whichever market kind owns the id. + pub fn register_cat_signer_spec( + env: Env, + market_id: u64, + spec: CatSignerConfig, + ) -> Result<(), Error> { + let rule = ResolutionSpec::CategoricalSigners(spec); + Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) + } + + /// See `register_price_spec` — same commit/reveal rule, scalar mode: + /// the market settles fractionally from the feed price clamped into + /// `[lower, upper]` (see `resolve_scalar_price`). + pub fn register_scalar_spec( + env: Env, + market_id: u64, + spec: ScalarPriceConfig, + ) -> Result<(), Error> { + let rule = ResolutionSpec::Scalar(spec); + Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) + } + /// Reveals a rule committed to through a [`ResolutionSpecV2`] envelope. /// The envelope must name this network and this resolver's market-core /// (`DomainMismatch`), use a non-zero salt and the current `version` @@ -451,16 +578,19 @@ impl Resolver { /// evidence surfaced during the dispute window that it is wrong. For /// address-based signers voting can restart once the counts are cleared by /// re-attesting; a weighted-key market moves to a fresh round, which makes - /// every signature collected so far unusable. + /// every signature collected so far unusable. Works for both the binary + /// and the categorical proposal slot. pub fn cancel_proposal(env: Env, market_id: u64) -> Result<(), Error> { Self::require_admin(&env)?; Self::assert_not_disputed(&env, market_id)?; let storage = env.storage().persistent(); let key = DataKey::Proposal(market_id); - if !storage.has(&key) { + let cat_key = DataKey::CatProposal(market_id); + if !storage.has(&key) && !storage.has(&cat_key) { return Err(Error::NoProposal); } storage.remove(&key); + storage.remove(&cat_key); if storage.has(&DataKey::WeightedKeys(market_id)) { Self::persist( &env, @@ -501,13 +631,35 @@ impl Resolver { Ok(outcome) } + /// Resolves a scalar market from its Reflector feed. The reading is + /// clamped into the committed `[lower, upper]` range and settled as the + /// fractional LONG payout `(clamped - lower) / (upper - lower)` in bps + /// (SHORT pays the complement). Returns the payout. + /// + /// A non-positive reading is `InvalidPrice` rather than a clamp: it means + /// the feed is broken, not that the value sits at the bottom of the + /// range. Same close-time, staleness and terminal-state rules as + /// `resolve_price`. + pub fn resolve_scalar_price(env: Env, market_id: u64) -> Result { + let config: ScalarPriceConfig = Self::load(&env, DataKey::Scalar(market_id))?; + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + let market = MarketCoreClient::new(&env, &market_core).get_market(&market_id); + if env.ledger().timestamp() < market.close_time { + return Err(Error::MarketNotClosed); + } + if Self::resolution_state(&env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + let (payout, data) = Self::try_scalar_payout(&env, &config, market.close_time)?; + Self::settle_scalar(&env, &market_core, market_id, payout, &data); + Ok(payout) + } + /// When `expire_market` becomes callable for `market_id`: `close_time` plus /// [`RESOLUTION_GRACE_SECS`]. Needs only market-core's `close_time`, so it - /// is known before (and without) a spec reveal. + /// is known before (and without) a spec reveal, for either market kind. pub fn resolution_deadline(env: Env, market_id: u64) -> Result { - let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let market = MarketCoreClient::new(&env, &market_core).get_market(&market_id); - Ok(Self::deadline(&market)) + Self::market_deadline(&env, market_id) } /// Permissionless liveness fallback: once `resolution_deadline` has passed, @@ -517,17 +669,15 @@ impl Resolver { /// Resolution is preferred over voiding. A price market whose feed is /// valid right now is *resolved* instead (so a stalled caller can never /// force a void on a resolvable market), and a market with a pending - /// signer proposal is refused with `ResolutionAvailable` until - /// `finalize_signers` settles it. + /// signer or categorical proposal is refused with `ResolutionAvailable` + /// until it is finalized. pub fn expire_market(env: Env, market_id: u64) -> Result<(), Error> { Self::assert_not_disputed(&env, market_id)?; if Self::resolution_state(&env, market_id).is_terminal() { return Err(Error::AlreadyFinalized); } - let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; - let core = MarketCoreClient::new(&env, &market_core); - let market = core.get_market(&market_id); - let deadline = Self::deadline(&market); + let close_time = Self::market_close_time(&env, market_id)?; + let deadline = close_time.saturating_add(RESOLUTION_GRACE_SECS); if env.ledger().timestamp() < deadline { return Err(Error::DeadlineNotReached); } @@ -536,8 +686,9 @@ impl Resolver { match mode { ExpiryMode::Price => { let config: PriceConfig = Self::load(&env, DataKey::Price(market_id))?; - match Self::try_price_outcome(&env, &config, market.close_time) { + match Self::try_price_outcome(&env, &config, close_time) { Ok((outcome, data)) => { + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; Self::settle_price(&env, &market_core, market_id, &outcome, &data); return Ok(()); } @@ -547,17 +698,35 @@ impl Resolver { Err(other) => return Err(other), } } - ExpiryMode::Signers if Self::has_valid_proposal(&env, market_id)? => { + ExpiryMode::Signers + if Self::has_valid_proposal(&env, market_id)? + || Self::has_valid_cat_proposal(&env, market_id)? => + { return Err(Error::ResolutionAvailable); } + ExpiryMode::Scalar => { + let config: ScalarPriceConfig = Self::load(&env, DataKey::Scalar(market_id))?; + match Self::try_scalar_payout(&env, &config, close_time) { + Ok((payout, data)) => { + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + Self::settle_scalar(&env, &market_core, market_id, payout, &data); + return Ok(()); + } + // A broken feed (missing, stale or non-positive) lets the + // market expire; any other error is a real fault. + Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) => {} + Err(other) => return Err(other), + } + } ExpiryMode::Signers | ExpiryMode::Unrevealed => {} } - core.void_market(&market_id); + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + MarketCoreClient::new(&env, &market_core).void_market(&market_id); Self::set_state(&env, market_id, ResolutionState::Expired); MarketExpired { market_id, - close_time: market.close_time, + close_time, deadline, mode, } @@ -728,6 +897,95 @@ impl Resolver { Ok(proposal.outcome) } + /// Categorical counterpart of `attest`: `signer` attests that + /// `outcome_index` is the winner. `outcome_index` must be below the + /// `outcome_count` the market's commitment fixed. Once `threshold` + /// members have attested an index it becomes the proposal and the + /// dispute window opens. + pub fn attest_cat( + env: Env, + market_id: u64, + signer: Address, + outcome_index: u32, + ) -> Result<(), Error> { + signer.require_auth(); + let config: CatSignerConfig = Self::load(&env, DataKey::CatSigners(market_id))?; + if !Self::contains(&config.signers, &signer) { + return Err(Error::NotSigner); + } + Self::assert_not_disputed(&env, market_id)?; + if Self::resolution_state(&env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + if outcome_index >= config.outcome_count { + return Err(Error::InvalidOutcomeIndex); + } + if env.storage().persistent().has(&DataKey::CatAttestation( + market_id, + outcome_index, + signer.clone(), + )) { + return Err(Error::AlreadyAttested); + } + if env.ledger().timestamp() < Self::market_close_time(&env, market_id)? { + return Err(Error::MarketNotClosed); + } + Self::persist( + &env, + &DataKey::CatAttestation(market_id, outcome_index, signer), + &true, + ); + let count_key = DataKey::CatCount(market_id, outcome_index); + let count = Self::cat_attestation_count(&env, market_id, outcome_index) + 1; + Self::persist(&env, &count_key, &count); + if count >= config.threshold + && !env + .storage() + .persistent() + .has(&DataKey::CatProposal(market_id)) + { + let proposal = CatProposal { + outcome_index, + proposed_at: env.ledger().timestamp(), + }; + Self::persist(&env, &DataKey::CatProposal(market_id), &proposal); + CatResolutionProposed { + market_id, + outcome_index, + proposed_at: proposal.proposed_at, + } + .publish(&env); + } + Ok(()) + } + + /// Categorical counterpart of `finalize_signers`: once the dispute window + /// of a proposal has passed and its index still has `threshold` + /// attestations, resolves the market in market-core and returns the + /// winning index. + pub fn finalize_cat(env: Env, market_id: u64) -> Result { + Self::assert_not_disputed(&env, market_id)?; + let config: CatSignerConfig = Self::load(&env, DataKey::CatSigners(market_id))?; + let proposal: CatProposal = + Self::load(&env, DataKey::CatProposal(market_id)).map_err(|_| Error::NoProposal)?; + if env.ledger().timestamp() < proposal.proposed_at + config.dispute_window { + return Err(Error::DisputeWindowOpen); + } + if Self::cat_attestation_count(&env, market_id, proposal.outcome_index) < config.threshold { + return Err(Error::ThresholdNotReached); + } + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + MarketCoreClient::new(&env, &market_core).resolve_cat(&market_id, &proposal.outcome_index); + Self::set_state(&env, market_id, ResolutionState::Finalized); + CatResolutionFinalized { + market_id, + outcome_index: proposal.outcome_index, + finalized_at: env.ledger().timestamp(), + } + .publish(&env); + Ok(proposal.outcome_index) + } + /// Challenges the current proposal of a `SignersDisputable` market while /// its dispute window is still open (strictly before `proposed_at + /// dispute_window`, the instant `finalize_signers` becomes callable). The @@ -923,17 +1181,43 @@ impl Resolver { .extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); } - fn deadline(market: &market_core::Market) -> u64 { - market.close_time.saturating_add(RESOLUTION_GRACE_SECS) + /// `close_time` of either market kind, so spec-independent entrypoints + /// (`expire_market`, `resolution_deadline`, `attest_cat`) work for + /// categorical markets too. + fn market_close_time(env: &Env, market_id: u64) -> Result { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + let core = MarketCoreClient::new(env, &market_core); + Ok(match core.market_kind(&market_id) { + MarketKind::Binary => core.get_market(&market_id).close_time, + MarketKind::Categorical(_) => core.get_cat_market(&market_id).close_time, + }) + } + + /// Committed `resolution_hash` of either market kind. + fn market_resolution_hash(env: &Env, market_id: u64) -> Result, Error> { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + let core = MarketCoreClient::new(env, &market_core); + Ok(match core.market_kind(&market_id) { + MarketKind::Binary => core.get_market(&market_id).resolution_hash, + MarketKind::Categorical(_) => core.get_cat_market(&market_id).resolution_hash, + }) + } + + /// `close_time + RESOLUTION_GRACE_SECS` for either market kind. + fn market_deadline(env: &Env, market_id: u64) -> Result { + Ok(Self::market_close_time(env, market_id)?.saturating_add(RESOLUTION_GRACE_SECS)) } fn expiry_mode(env: &Env, market_id: u64) -> ExpiryMode { let storage = env.storage().persistent(); if storage.has(&DataKey::Price(market_id)) { ExpiryMode::Price + } else if storage.has(&DataKey::Scalar(market_id)) { + ExpiryMode::Scalar } else if storage.has(&DataKey::Signers(market_id)) || storage.has(&DataKey::Disputable(market_id)) || storage.has(&DataKey::WeightedKeys(market_id)) + || storage.has(&DataKey::CatSigners(market_id)) { ExpiryMode::Signers } else { @@ -1061,6 +1345,32 @@ impl Resolver { )) } + fn cat_attestation_count(env: &Env, market_id: u64, outcome_index: u32) -> u32 { + env.storage() + .persistent() + .get(&DataKey::CatCount(market_id, outcome_index)) + .unwrap_or(0) + } + + /// Categorical counterpart of `has_valid_proposal`: a `CatProposal` exists + /// whose attested count still meets the configured threshold, so + /// `finalize_cat` will succeed once the dispute window elapses. + fn has_valid_cat_proposal(env: &Env, market_id: u64) -> Result { + let Some(proposal) = env + .storage() + .persistent() + .get::<_, CatProposal>(&DataKey::CatProposal(market_id)) + else { + return Ok(false); + }; + let config: CatSignerConfig = env + .storage() + .persistent() + .get(&DataKey::CatSigners(market_id)) + .ok_or(Error::MarketNotConfigured)?; + Ok(Self::cat_attestation_count(env, market_id, proposal.outcome_index) >= config.threshold) + } + /// Sets a persistent entry and extends its TTL. fn persist>( env: &Env, @@ -1213,6 +1523,50 @@ impl Resolver { .publish(env); } + /// Scalar counterpart of `try_price_outcome`: same feed read and + /// staleness rules, then the clamped in-range position as bps. The + /// multiply cannot overflow `i128` because `validate_rule` caps `upper` + /// at `i128::MAX / BPS` and `lower >= 0`, so + /// `0 <= clamped - lower <= upper - lower <= i128::MAX / BPS`. + fn try_scalar_payout( + env: &Env, + config: &ScalarPriceConfig, + close_time: u64, + ) -> Result<(u32, PriceData), Error> { + let Ok(Ok(Some(data))) = + ReflectorPriceClient::new(env, &config.reflector).try_price(&config.asset, &close_time) + else { + return Err(Error::PriceUnavailable); + }; + if data.timestamp > close_time || close_time - data.timestamp > config.max_staleness { + return Err(Error::StalePrice); + } + if data.price <= 0 { + return Err(Error::InvalidPrice); + } + let clamped = data.price.clamp(config.lower, config.upper); + let payout = (clamped - config.lower) * BPS / (config.upper - config.lower); + Ok((payout as u32, data)) + } + + fn settle_scalar( + env: &Env, + market_core: &Address, + market_id: u64, + payout_long_bps: u32, + data: &PriceData, + ) { + MarketCoreClient::new(env, market_core).resolve_scalar(&market_id, &payout_long_bps); + Self::set_state(env, market_id, ResolutionState::Finalized); + ScalarPriceResolution { + market_id, + price: data.price, + price_timestamp: data.timestamp, + payout_long_bps, + } + .publish(env); + } + fn contains(list: &Vec

, address: &Address) -> bool { list.iter().any(|candidate| candidate == *address) } @@ -1226,6 +1580,8 @@ impl Resolver { || storage.has(&DataKey::Signers(market_id)) || storage.has(&DataKey::WeightedKeys(market_id)) || storage.has(&DataKey::Disputable(market_id)) + || storage.has(&DataKey::CatSigners(market_id)) + || storage.has(&DataKey::Scalar(market_id)) { return Err(Error::MarketAlreadyConfigured); } @@ -1261,6 +1617,21 @@ impl Resolver { } ResolutionSpec::WeightedKeys(cfg) => Self::weighted_config_is_valid(cfg), ResolutionSpec::SignersDisputable(cfg) => Self::disputable_config_is_valid(cfg), + ResolutionSpec::CategoricalSigners(cfg) => { + !cfg.signers.is_empty() + && cfg.threshold != 0 + && cfg.threshold <= cfg.signers.len() + && cfg.dispute_window != 0 + && (2..=MAX_CAT_OUTCOMES).contains(&cfg.outcome_count) + } + // `upper <= i128::MAX / BPS` keeps `try_scalar_payout`'s multiply + // provably overflow-free. + ResolutionSpec::Scalar(cfg) => { + cfg.lower >= 0 + && cfg.lower < cfg.upper + && cfg.upper <= i128::MAX / BPS + && cfg.max_staleness != 0 + } }; if valid { Ok(()) @@ -1292,20 +1663,29 @@ impl Resolver { storage.set(&key, cfg); (key, ResolutionState::SignersPending) } + ResolutionSpec::CategoricalSigners(cfg) => { + let key = DataKey::CatSigners(market_id); + storage.set(&key, cfg); + (key, ResolutionState::SignersPending) + } + ResolutionSpec::Scalar(cfg) => { + let key = DataKey::Scalar(market_id); + storage.set(&key, cfg); + (key, ResolutionState::Unconfigured) + } }; storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); Self::set_state(env, market_id, state); } - /// Verifies `commitment` equals the market's `resolution_hash`. + /// Verifies `commitment` equals the market's `resolution_hash`, read from + /// whichever market kind owns the id (binary or categorical). fn assert_matches_commitment( env: &Env, market_id: u64, commitment: &BytesN<32>, ) -> Result<(), Error> { - let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; - let market = MarketCoreClient::new(env, &market_core).get_market(&market_id); - if *commitment != market.resolution_hash { + if *commitment != Self::market_resolution_hash(env, market_id)? { return Err(Error::SpecHashMismatch); } Ok(()) @@ -1315,13 +1695,14 @@ impl Resolver { #[cfg(test)] mod test { use super::*; - use market_core::{Category, MarketStatus}; + use market_core::{CatStatus, Category, MarketStatus}; use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Events as _, Ledger, MockAuth, MockAuthInvoke}, token::{self, StellarAssetClient}, BytesN, IntoVal, }; + use std::format; #[contract] struct MockReflector; @@ -1491,6 +1872,163 @@ mod test { }) } + fn cat_signer_spec(env: &Env, a: &Address, b: &Address, outcome_count: u32) -> CatSignerConfig { + CatSignerConfig { + signers: Vec::from_array(env, [a.clone(), b.clone()]), + threshold: 2, + dispute_window: 100, + outcome_count, + } + } + + /// A fixture whose market is categorical (3 outcomes, seeded from + /// [5000, 3000, 2000] bps) and whose commitment is the legacy hash of the + /// `CategoricalSigners` rule `spec_fn` returns. + fn cat_fixture(spec_fn: impl FnOnce(&Env, &Address, &Address) -> CatSignerConfig) -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(MockReflector, ()); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let token = StellarAssetClient::new(&env, &collateral.address()); + token.mint(&creator, &(1_000 * 10_000_000_i128)); + + let spec = ResolutionSpec::CategoricalSigners(spec_fn(&env, &signer_a, &signer_b)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury.clone(), + resolver_id.clone(), + 100u32, + 10_000u32, + ), + ); + let resolution_hash = legacy_spec_hash(&env, &spec); + let market_core = MarketCoreClient::new(&env, &market_core_id); + let market_id = market_core.create_categorical_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-cat-test"), + &Category::Crypto, + &2_000, + &1_000_000_000_i128, + &Vec::from_array(&env, [5_000u32, 3_000, 2_000]), + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + } + } + + fn scalar_spec(env: &Env, reflector: &Address) -> ScalarPriceConfig { + ScalarPriceConfig { + reflector: reflector.clone(), + asset: OracleAsset::Other(Symbol::new(env, "XLM")), + lower: 100, + upper: 250, + max_staleness: 10, + } + } + + /// A fixture whose market is scalar over `[100, 250]` and whose commitment + /// is the legacy hash of the `Scalar` rule `spec_fn` returns. + fn scalar_fixture(spec_fn: impl FnOnce(&Env, &Address) -> ScalarPriceConfig) -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(MockReflector, ()); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let token = StellarAssetClient::new(&env, &collateral.address()); + token.mint(&creator, &(1_000 * 10_000_000_i128)); + + let spec = ResolutionSpec::Scalar(spec_fn(&env, &reflector_id)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury.clone(), + resolver_id.clone(), + 100u32, + 10_000u32, + ), + ); + let resolution_hash = legacy_spec_hash(&env, &spec); + let market_core = MarketCoreClient::new(&env, &market_core_id); + let ResolutionSpec::Scalar(cfg) = &spec else { + unreachable!() + }; + let market_id = market_core.create_scalar_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-scalar-test"), + &Category::Crypto, + &2_000, + &1_000_000_000_i128, + &5000u32, + &cfg.lower, + &cfg.upper, + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + } + } + #[test] fn price_mode_resolves_from_historical_reflector_price() { let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); @@ -1534,6 +2072,388 @@ mod test { ); } + #[test] + fn cat_signer_mode_requires_threshold_and_waits_for_dispute_window() { + let f = cat_fixture(|env, a, b| cat_signer_spec(env, a, b, 3)); + let ResolutionSpec::CategoricalSigners(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_cat_signer_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + f.resolver.attest_cat(&f.market_id, &f.signer_a, &2u32); + f.resolver.attest_cat(&f.market_id, &f.signer_b, &2u32); + + assert_eq!( + f.resolver.try_finalize_cat(&f.market_id), + Err(Ok(Error::DisputeWindowOpen)) + ); + f.env.ledger().set_timestamp(2_100); + assert_eq!(f.resolver.finalize_cat(&f.market_id), 2u32); + assert_eq!( + f.market_core.get_cat_market(&f.market_id).status, + CatStatus::Resolved(2) + ); + } + + #[test] + fn cat_attest_validations() { + let f = cat_fixture(|env, a, b| cat_signer_spec(env, a, b, 3)); + + // Nothing revealed yet: attestations cannot be recorded. + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &0u32), + Err(Ok(Error::MarketNotConfigured)) + ); + + let ResolutionSpec::CategoricalSigners(spec) = f.spec.clone() else { + unreachable!() + }; + // Outcome counts outside 2..=8 and a zero threshold are rejected by + // validation before the commitment is checked. + for bad_count in [1u32, 9] { + let mut bad = spec.clone(); + bad.outcome_count = bad_count; + assert_eq!( + f.resolver.try_register_cat_signer_spec(&f.market_id, &bad), + Err(Ok(Error::InvalidConfig)) + ); + } + let mut bad = spec.clone(); + bad.threshold = 0; + assert_eq!( + f.resolver.try_register_cat_signer_spec(&f.market_id, &bad), + Err(Ok(Error::InvalidConfig)) + ); + // A valid-but-different rule does not match the committed hash. + let mut wrong = spec.clone(); + wrong.threshold = 1; + assert_eq!( + f.resolver + .try_register_cat_signer_spec(&f.market_id, &wrong), + Err(Ok(Error::SpecHashMismatch)) + ); + f.resolver.register_cat_signer_spec(&f.market_id, &spec); + // One reveal per market. + assert_eq!( + f.resolver.try_register_cat_signer_spec(&f.market_id, &spec), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + + // Attesting before close is rejected. + f.env.ledger().set_timestamp(1_500); + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &0u32), + Err(Ok(Error::MarketNotClosed)) + ); + f.env.ledger().set_timestamp(2_000); + // Only configured signers can attest. + let outsider = Address::generate(&f.env); + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &outsider, &0u32), + Err(Ok(Error::NotSigner)) + ); + // Indices at or above the committed outcome count are rejected. + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &3u32), + Err(Ok(Error::InvalidOutcomeIndex)) + ); + // An attestation is recorded at most once per signer and index. + f.resolver.attest_cat(&f.market_id, &f.signer_a, &1u32); + assert_eq!( + f.resolver.try_attest_cat(&f.market_id, &f.signer_a, &1u32), + Err(Ok(Error::AlreadyAttested)) + ); + } + + #[test] + fn cat_proposal_opens_with_quorum_and_guards_expiry() { + let f = cat_fixture(|env, a, b| cat_signer_spec(env, a, b, 3)); + let ResolutionSpec::CategoricalSigners(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_cat_signer_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + // No proposal until `threshold` signers attest the same index. + assert_eq!( + f.resolver.try_finalize_cat(&f.market_id), + Err(Ok(Error::NoProposal)) + ); + f.resolver.attest_cat(&f.market_id, &f.signer_a, &0u32); + f.resolver.attest_cat(&f.market_id, &f.signer_b, &1u32); + assert_eq!( + f.resolver.try_finalize_cat(&f.market_id), + Err(Ok(Error::NoProposal)) + ); + f.resolver.attest_cat(&f.market_id, &f.signer_b, &0u32); + + // Past the grace deadline a live proposal still blocks expiry... + f.env + .ledger() + .set_timestamp(2_000 + RESOLUTION_GRACE_SECS + 1); + assert_eq!( + f.resolver.try_expire_market(&f.market_id), + Err(Ok(Error::ResolutionAvailable)) + ); + // ...until the guardian cancels it, after which the market voids. + f.resolver.cancel_proposal(&f.market_id); + f.resolver.expire_market(&f.market_id); + assert_eq!( + f.market_core.get_cat_market(&f.market_id).status, + CatStatus::Void + ); + } + + #[test] + fn scalar_price_mode_resolves_mid_range() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + reflector.set_price(&176_i128, &1_999_u64); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + // (176 - 100) / (250 - 100) in bps: 760_000 / 150 = 5066.67 -> 5066. + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 5_066); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::ResolvedScalar(5_066) + ); + // One-shot: the market is terminal after resolution. + assert_eq!( + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::AlreadyFinalized)) + ); + } + + #[test] + fn scalar_price_clamps_below_lower() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + // 50 is below `lower` (100): the payout clamps to zero instead of + // reverting, so the market always resolves. + reflector.set_price(&50_i128, &1_999_u64); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 0); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::ResolvedScalar(0) + ); + } + + #[test] + fn scalar_price_clamps_above_upper() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + // 400 is above `upper` (250): the payout saturates at 10_000 bps. + reflector.set_price(&400_i128, &1_999_u64); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 10_000); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::ResolvedScalar(10_000) + ); + } + + #[test] + fn scalar_rejects_non_positive_price() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + // A non-positive reading is a broken feed, not a value at the bottom + // of the range, so it must not clamp silently. + for bad in [0_i128, -5] { + reflector.set_price(&bad, &1_999_u64); + assert_eq!( + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::InvalidPrice)) + ); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Open + ); + } + + // The rejection is recoverable: a healthy reading resolves normally. + reflector.set_price(&200_i128, &1_999_u64); + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 6_666); + } + + #[test] + fn scalar_rejects_stale_price() { + let f = scalar_fixture(scalar_spec); + let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); + + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_scalar_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + + // One second older than `max_staleness` (10) is stale... + reflector.set_price(&176_i128, &1_989_u64); + assert_eq!( + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + // ...a future-dated reading is stale too... + reflector.set_price(&176_i128, &2_001_u64); + assert_eq!( + f.resolver.try_resolve_scalar_price(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + // ...and exactly `max_staleness` old is the last accepted age. + reflector.set_price(&176_i128, &1_990_u64); + assert_eq!(f.resolver.resolve_scalar_price(&f.market_id), 5_066); + } + + #[test] + fn scalar_spec_registration_validations() { + let f = scalar_fixture(scalar_spec); + let ResolutionSpec::Scalar(spec) = f.spec.clone() else { + unreachable!() + }; + + // The reveal is validated before the commitment is checked. + for (lower, upper, max_staleness) in [ + (100_i128, 100_i128, 10u64), // empty range + (250, 100, 10), // inverted range + (-1, 250, 10), // negative lower + (0, i128::MAX / BPS + 1, 10), + (100, 250, 0), // staleness window disabled + ] { + let mut bad = spec.clone(); + bad.lower = lower; + bad.upper = upper; + bad.max_staleness = max_staleness; + assert_eq!( + f.resolver.try_register_scalar_spec(&f.market_id, &bad), + Err(Ok(Error::InvalidConfig)) + ); + } + + // A valid-but-different rule does not match the committed hash. + let mut wrong = spec.clone(); + wrong.upper = 251; + assert_eq!( + f.resolver.try_register_scalar_spec(&f.market_id, &wrong), + Err(Ok(Error::SpecHashMismatch)) + ); + + f.resolver.register_scalar_spec(&f.market_id, &spec); + // One reveal per market, even with the identical rule. + assert_eq!( + f.resolver.try_register_scalar_spec(&f.market_id, &spec), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + } + + #[test] + fn scalar_spec_hash_disjoint_from_price() { + // A price rule revealed against a scalar commitment mismatches... + let f = scalar_fixture(scalar_spec); + let as_price = PriceConfig { + reflector: f.reflector_id.clone(), + asset: OracleAsset::Other(Symbol::new(&f.env, "XLM")), + threshold: 100, + direction: PriceDirection::Above, + max_staleness: 10, + }; + assert_eq!( + f.resolver.try_register_price_spec(&f.market_id, &as_price), + Err(Ok(Error::SpecHashMismatch)) + ); + + // ...and a scalar rule cannot be revealed against a price commitment. + let g = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let as_scalar = ScalarPriceConfig { + reflector: g.reflector_id.clone(), + asset: OracleAsset::Other(Symbol::new(&g.env, "XLM")), + lower: 100, + upper: 250, + max_staleness: 10, + }; + assert_eq!( + g.resolver + .try_register_scalar_spec(&g.market_id, &as_scalar), + Err(Ok(Error::SpecHashMismatch)) + ); + } + + /// Appending `ResolutionSpec::Scalar` must not move the commitment of any + /// variant that existed before it. These hashes pre-date scalar support; + /// they are re-pinned here through the production `legacy_spec_hash` path + /// (the `golden` module pins the same bytes via raw `sha256(to_xdr)`). + #[test] + fn existing_spec_hashes_unchanged() { + let env = Env::default(); + let hex = |h: &BytesN<32>| -> std::string::String { + h.to_array().iter().map(|b| format!("{b:02x}")).collect() + }; + let g1 = Address::from_str( + &env, + "GBAKBHXK7SV7C3HGY3DDYB4QWUEGL52SEF6OO77LAD672JVXD4QCYJLF", + ); + + let price = ResolutionSpec::Price(PriceConfig { + reflector: Address::from_str( + &env, + "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ", + ), + asset: OracleAsset::Other(Symbol::new(&env, "XLM")), + threshold: 2_000, + direction: PriceDirection::Above, + max_staleness: 10, + }); + assert_eq!( + hex(&legacy_spec_hash(&env, &price)), + "b6f2458326d5f76bd9b53a93a103ea6c0fed7129c5496e91e3faef9a9a23d469", + ); + + let signers = ResolutionSpec::Signers(SignerConfig { + signers: Vec::from_array(&env, [g1.clone()]), + threshold: 1, + dispute_window: 5, + }); + assert_eq!( + hex(&legacy_spec_hash(&env, &signers)), + "b935a3beeebdbf7b9defc916410f19a1740a3920623230152e41a812b6f141c4", + ); + + let cat = ResolutionSpec::CategoricalSigners(CatSignerConfig { + signers: Vec::from_array(&env, [g1]), + threshold: 1, + dispute_window: 5, + outcome_count: 2, + }); + assert_eq!( + hex(&legacy_spec_hash(&env, &cat)), + "16db57d51742d266dccd1a70539afc160971c942605a0ae8f15b5a18cd1b2837", + ); + } + #[test] fn rejects_revealed_spec_with_wrong_commitment() { let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); @@ -2867,6 +3787,25 @@ mod golden { }) } + fn cat_signers( + env: &Env, + keys: &[&str], + outcome_count: u32, + threshold: u32, + dispute_window: u64, + ) -> ResolutionSpec { + let mut signers = Vec::new(env); + for key in keys { + signers.push_back(Address::from_str(env, key)); + } + ResolutionSpec::CategoricalSigners(CatSignerConfig { + signers, + threshold, + dispute_window, + outcome_count, + }) + } + fn price( env: &Env, asset: OracleAsset, @@ -2883,11 +3822,55 @@ mod golden { }) } + fn scalar( + env: &Env, + asset: OracleAsset, + lower: i128, + upper: i128, + max_staleness: u64, + ) -> ResolutionSpec { + ResolutionSpec::Scalar(ScalarPriceConfig { + reflector: Address::from_str(env, C1), + asset, + lower, + upper, + max_staleness, + }) + } + fn hex_of(env: &Env, spec: &ResolutionSpec) -> String { let hash: BytesN<32> = env.crypto().sha256(&spec.clone().to_xdr(env)).into(); hash.to_array().iter().map(|b| format!("{b:02x}")).collect() } + /// Shared byte-for-byte with `golden_scalar_vectors` in + /// `scripts/spec-hash`: the scalar variant (tag 5) mirrors the price + /// fields, so it must be pinned against drifting into the price hash + /// space. + #[test] + fn golden_legacy_scalar_spec_hashes() { + let env = Env::default(); + let xlm = || OracleAsset::Other(Symbol::new(&env, "XLM")); + let asset = |id: &str| OracleAsset::Stellar(Address::from_str(&env, id)); + let vectors = [ + ( + scalar(&env, xlm(), 100, 250, 10), + "e97c4bccc39a4538850b22e703019c3b69971a00b99bc73117cc67e044e26822", + ), + ( + scalar(&env, asset(C2), 0, 1_000_000_000_000, 300), + "1d94380e9112c6f80f7542106dcc75ffc1426a634da2a07440fac72d2033fa08", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hex_of(&env, &spec), expected); + } + assert_ne!( + hex_of(&env, &scalar(&env, xlm(), 2_000, 2_000, 10)), + hex_of(&env, &price(&env, xlm(), 2_000, PriceDirection::Above, 10)), + ); + } + #[test] fn golden_legacy_signer_spec_hashes() { let env = Env::default(); @@ -2906,6 +3889,32 @@ mod golden { } } + /// Shared byte-for-byte with `golden_cat_signers_vectors` in + /// `scripts/spec-hash`: the CLI parsers and the contract types must produce + /// byte-identical commitments, and the appended variant must not collide + /// with any existing one. + #[test] + fn golden_legacy_cat_signer_spec_hashes() { + let env = Env::default(); + let vectors = [ + ( + cat_signers(&env, &[G1], 2, 1, 5), + "16db57d51742d266dccd1a70539afc160971c942605a0ae8f15b5a18cd1b2837", + ), + ( + cat_signers(&env, &[G1, G2, G3], 3, 2, 3_600), + "21b750237b5cb3f54bdc71ec55a1d8073a3b860f6cb3cb3b9717570dc42092e7", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hex_of(&env, &spec), expected); + } + assert_ne!( + hex_of(&env, &cat_signers(&env, &[G1], 2, 1, 5)), + hex_of(&env, &signers(&env, &[G1], 1, 5)), + ); + } + #[test] fn golden_legacy_price_spec_hashes() { let env = Env::default(); diff --git a/oracledesk-stellar/contracts/treasury/market_core.wasm b/oracledesk-stellar/contracts/treasury/market_core.wasm index 4e6bdb172653cdf7c3445ad225b792471d2ad601..d6b64888ebe7b116b56281b933f8168b5877fa8a 100644 GIT binary patch literal 76343 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

B!U*OGHX)3zDBb%Xdm@8`FvaaRCnkxqMM& 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 Result { + Self::assert_not_paused(&env)?; + let config = Self::get_config(env.clone())?; + config.agent.require_auth(); + if collateral_in <= 0 { + return Err(Error::InvalidAmount); + } + if collateral_in > config.max_trade { + return Err(Error::TradeCapExceeded); + } + + let treasury_addr = env.current_contract_address(); + Self::authorize_token_pull(&env, &config, &treasury_addr, collateral_in); + let shares = MarketCoreClient::new(&env, &config.market_core).buy_cat( + &treasury_addr, + &market_id, + &outcome_index, + &collateral_in, + &min_shares_out, + ); + Self::reserve_capacity(&env, &config, market_id, collateral_in)?; + Ok(shares) + } + /// Sells `outcome` shares back into the pool for exactly /// `collateral_out`. This returns capital to the treasury (market-core /// pays out of its own balance), so it is not capped and does not need @@ -677,7 +711,7 @@ mod test { use soroban_sdk::{ testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, token::StellarAssetClient, - BytesN, String, + BytesN, String, Vec, }; // Comfortably past every timestamp advance the tests make, so a market @@ -1218,6 +1252,77 @@ mod test { assert!(shares > 0); } + /// Creates a 3-outcome categorical market seeded by the fixture creator. + fn create_cat_market(f: &Fixture, nonce: u8) -> u64 { + let creator = f.market_core.get_market(&f.market_id).creator; + f.market_core.create_categorical_market( + &creator, + &BytesN::from_array(&f.env, &[nonce; 32]), + &BytesN::from_array(&f.env, &[nonce + 100; 32]), + &String::from_str(&f.env, "ipfs://treasury-cat-market"), + &Category::Crypto, + &MARKET_CLOSE, + &(100 * 10_000_000_i128), + &Vec::from_array(&f.env, [5_000u32, 3_000, 2_000]), + ) + } + + #[test] + fn agent_buy_cat_respects_trade_and_market_caps() { + let f = fixture(); + let cat_market = create_cat_market(&f, 18); + + // Same single-trade cap as the binary path. + assert_eq!( + f.treasury + .try_agent_buy_cat(&cat_market, &0u32, &(21 * 10_000_000_i128), &0), + Err(Ok(Error::TradeCapExceeded)) + ); + // Two capped buys fill the per-market cap; a third — even on another + // outcome — is rejected. + f.treasury + .agent_buy_cat(&cat_market, &0u32, &(20 * 10_000_000_i128), &0); + f.treasury + .agent_buy_cat(&cat_market, &2u32, &(20 * 10_000_000_i128), &0); + assert_eq!( + f.treasury + .try_agent_buy_cat(&cat_market, &1u32, &(20 * 10_000_000_i128), &0), + Err(Ok(Error::MarketCapExceeded)) + ); + assert_eq!( + f.treasury.try_close_trade(&cat_market), + Ok(Ok(40 * 10_000_000_i128)) + ); + } + + /// The categorical twin of the `agent_buy` pitfall test: `buy_cat`'s + /// nested token transfer must be pre-authorized too (`mock_auths` for + /// only the agent, so a regression in `authorize_token_pull` fails here). + #[test] + fn pitfall_agent_buy_cat_forwards_nested_transfer_auth() { + let f = fixture(); + let cat_market = create_cat_market(&f, 19); + let agent = f.treasury.get_config().agent; + let outcome_index = 1u32; + let amount = 20 * 10_000_000_i128; + let min_out = 0_i128; + + f.env.set_auths(&[]); + let shares = f + .treasury + .mock_auths(&[MockAuth { + address: &agent, + invoke: &MockAuthInvoke { + contract: &f.treasury_id, + fn_name: "agent_buy_cat", + args: (cat_market, outcome_index, amount, min_out).into_val(&f.env), + sub_invokes: &[], + }, + }]) + .agent_buy_cat(&cat_market, &outcome_index, &amount, &min_out); + assert!(shares > 0); + } + #[test] fn migrate_market_moves_position_and_lp_to_successor() { let f = fixture_with_caps( diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index c542497..d61de65 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -1,7 +1,7 @@ # Status -Last updated: 2026-09-21 (contributor addendum 2026-10-16 — see the first -section after this intro). This is the honest account of what works, what's +Last updated: 2026-09-21 (contributor addenda 2026-10-16 — see the sections +after this intro). This is the honest account of what works, what's unverified, and what's left. Everything marked "verified" was demonstrated with real command output in the session that wrote this file, not asserted. @@ -22,6 +22,48 @@ contracts and `deployments/testnet.json` predate these changes, so the new entrypoints have contract-level and off-chain tests only, not a real-ledger transaction. +## Contributor addendum: 2026-10-16 — categorical (#6), scalar (#7), dynamic fees (#9) + +Implemented together on `feat/categorical-scalar-dynamic-fees` (PR against +`develop`): n-outcome categorical markets (2..=8 — +`create_categorical_market` / `buy_cat` / `sell_cat` / `resolve_cat` / +`redeem_cat` / `claim_pool_remainder_cat`), scalar range markets +(`create_scalar_market` / `resolve_scalar` / `get_scalar_meta`, settled from +a Reflector feed via `resolver.resolve_scalar_price`), and per-market +dynamic fee schedules (`FeeSchedule` / `effective_fee_bps`, +`create_market_with_fees`, `quote_buy_at`, `current_fee_bps`); the resolver +also gained categorical signer resolution (`register_cat_signer_spec` / +`attest_cat` / `finalize_cat`) and the treasury an `agent_buy_cat` +forwarder. Design and invariants: `docs/architecture.md`; storage and +append-only-XDR decision: [ADR 0005](adr/0005-market-kind-storage-split.md). + +Verified in the contributor session: `cargo fmt --check`, `cargo clippy +--workspace --all-targets -- -D warnings`, `cargo test --workspace` (now +**208** tests: market-core 97, resolver 82, treasury 23, reasoning-registry +6 — superseding the 87-test count above), the `scripts/spec-hash` crate (11 +tests), `python3 model/fpmm_model.py`, agents (52 tests) and x402 (5 +tests), plus `make build` (vendored wasm regenerated) and `make bindings` +with only the intended diffs. The new math is mirrored in all three +languages against one shared fixture file (`model/fee_fixtures.json`) that +Python, Rust and TypeScript all read. Worst-case categorical cost was +measured, not estimated: `buy_cat` at `n = 8` takes 444,604 instructions / +64,092 B memory / 1,640 write bytes across 5 write entries — under 60% of +the network limits, asserted by the `cat_eight_outcomes_resource_budget` +test. + +**Release note for bindings consumers:** `MarketStatus` and +`ResolutionSpec` gained *appended* variants (`ResolvedScalar`, +`CategoricalSigners`, `Scalar`). Values using them cannot be decoded by +bindings generated before this change — regenerate; the committed +`packages/bindings` in this PR are regenerated. + +**Not** (re)verified: no testnet redeploy — the deployed contracts and +`deployments/testnet.json` predate these changes, so the new entrypoints +have contract-level and off-chain tests only, not a real-ledger +transaction. Categorical markets keep a flat fee (the dynamic schedule +applies to binary and scalar markets) and neither new kind has a live +testnet transaction yet. + ## Phase 0: recon and safety **Toolchain** (as installed in the environment this was built in): diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md index b902ee0..0f5857b 100644 --- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md +++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md @@ -56,6 +56,14 @@ The commitment format is extended by [ADR 0004](0004-spec-versioning.md): a salted, versioned, domain-separated `ResolutionSpecV2` envelope accepted alongside the legacy format described above. +The `ResolutionSpec` enum gains two **appended** variants — +`CategoricalSigners` (tag 4, for n-outcome markets) and `Scalar` (tag 5, a +price spec that resolves to a payout in basis points rather than an +outcome). Appending preserves the disjointness argument above: existing +variants keep their tags, so every already-committed hash is unchanged, and +each new rule lives in its own hash space. See +[ADR 0005](0005-market-kind-storage-split.md). + ## Liveness fallback A market whose spec is never revealed, whose committed feed is permanently diff --git a/oracledesk-stellar/docs/adr/0005-market-kind-storage-split.md b/oracledesk-stellar/docs/adr/0005-market-kind-storage-split.md new file mode 100644 index 0000000..58f5593 --- /dev/null +++ b/oracledesk-stellar/docs/adr/0005-market-kind-storage-split.md @@ -0,0 +1,116 @@ +# ADR 0005: Market kinds as side storage, append-only XDR variants + +## Status + +Accepted. + +## Context + +Categorical (n-outcome, #6) and scalar (range, #7) markets arrived after +`market-core` had shipped binary markets and after the resolution commitment +of [ADR 0001](0001-resolution-commitment.md) had pinned the hash of every +committed `ResolutionSpec`. Adding them raised a storage question: where do +the new market shapes live? + +Three forces collide: + +- **`Market`'s XDR is consensus-critical material.** It is hashed into + nothing directly, but it is embedded in events (`MarketCreated` and + friends), read by deployed bindings, and mirrored off-chain. Appending a + field (say `kind`) changes the encoding of every existing value — structs + encode as maps sorted by field name, so even an appended field reorders + nothing but changes the map length and content. Deployed readers would + fail to decode, and the change would be indistinguishable from a rename + for any consumer that pins bytes. +- **The `market_id` space is shared.** Markets are created against one + monotonic id counter; a categorical market does not get, and should not + spend, a second id. +- **New enum variants are unavoidable somewhere.** A resolution rule and a + status must express "categorical" and "scalar", and an audit of ADR 0001's + hash spaces shows both enums are in scope (see below). + +## Decision + +Never change the binary `Market` struct. Each new market kind is a +**separate persistent entry keyed by the same `market_id`**: + +- `DataKey::Kind(market_id) -> MarketKind` — `Binary`, `Categorical(u32)`, + or the scalar kind; the registry of what a given id is. `market_kind` + exposes it (returning `Binary` for ids created before the key existed — + the old shape is the default, so no migration transaction). +- `CatMarket` / `CatPosition` — categorical state and holdings + (`reserves: Vec`, one vec entry per outcome). +- `ScalarMeta` — scalar display metadata (`lower`, `upper`); the scalar + market's pool *is* the binary `Market` (LONG = YES, SHORT = NO), so + trading, fees, redemption safety and the solvency invariant are reused + unchanged rather than reimplemented. +- `FeeSchedule` (#9) — a per-market dynamic fee side entry; absent means the + binary default (flat `fee_bps`), so again no migration. + +Entrypoints dispatch on `market_kind`: `get_market` returns +`MarketNotFound` for a categorical id (its shape is `get_cat_market`), +`buy_cat` / `sell_cat` / `resolve_cat` / `redeem_cat` refuse binary ids and +vice versa. + +Enum changes are **append-only**: + +- `MarketStatus` gains `ResolvedScalar(u32)` as the *last* variant (tag 3). + Scalar resolution does not reuse `Resolved(Outcome)` because a scalar + payout is not a binary outcome, and reusing it would silently make old + readers interpret a 0–10000 payout as Yes/No. +- `ResolutionSpec` gains `CategoricalSigners(CatSignerConfig)` (tag 4) and + `Scalar(ScalarPriceConfig)` (tag 5), appended after the four variants + ADR 0001 and ADR 0004 already fixed. The spec hash spaces stay disjoint: + a scalar spec hashes as the `Scalar` envelope, never as a bare + `PriceConfig`. +- `MarketKind` itself is a fresh type; only its `Binary` tag is load-bearing + (it is the fallback for absent keys), and it is encoded with `Binary` + first from the start. + +The categorical pool reuses the binary FPMM's exact math shape — the +`calc_buy` round-trip from the multi-asset constant-product formulation — +with per-outcome reserves renormalized by `Π(r_others)`, so the same +floor/ceil directions (traders never gain a stroop from rounding, `k` never +decreases) carry over; the invariants are property-tested in all three +mirrors rather than re-proven on paper here. + +## Consequences + +- **Deployed binary readers keep working.** Old bindings and indexers see + byte-identical `Market`, `MarketStatus` (for tags 0–2), and `ResolutionSpec` + (tags 0–3) encodings. The cost: a reader that does not know about appended + variants will fail to decode *new* values (a scalar-resolved market's + status has tag 3) — that is the intended, visible failure mode, and it is a + release note, not a compatibility shim. `docs/STATUS.md` and the PR carry + it. +- **Readers must dispatch.** Anything fetching a market from an id alone + reads `market_kind` first. This is a modest off-chain burden and it is + uniform across the three kinds. +- **Golden vectors pin the encodings.** `ResolutionSpec` XDR hashes are + pinned in contract golden modules and `scripts/spec-hash` (3-way verified + against Python and `stellar xdr decode`), so an accidental reorder of the + variant list — which would change every committed hash — fails a test + instead of a market. +- **`n` is bounded at 8** (`MAX_MARKET_OUTCOMES`). The measured worst case + stays under 60% of Soroban limits (see `docs/architecture.md`), and quoting + an n=8 buy touches 5 storage entries — the cap is a transaction-footprint + cap, not just a gas estimate. +- The categorical market keeps a **flat** `fee_bps` (snapshotted like the + binary one). The dynamic `FeeSchedule` currently applies only to binary + and scalar markets; extending it is additive via the same side entry. + +## Alternatives considered + +- **Extend `Market` with a `kind` field / optional vecs.** Breaks the XDR of + every existing market and every event that carries one; rejected outright + under the "consensus-critical" constraint. +- **A separate `market-core-cat` contract.** Splits the id space (categorical + ids would collide with binary ones or require a global counter anyone can + increment — a griefing vector), duplicates fee/treasury/resolver wiring, + and forces the resolver to know two addresses for "the market with id N". +- **A wrapper enum `MarketValue { Binary(Market), Categorical(...) }` under + the existing key.** Same XDR breakage as extending `Market`: the stored + value's encoding changes for every existing market. +- **Reusing `MarketStatus::Resolved(Outcome)` for scalar payouts.** Loses the + payout value entirely (a binary outcome cannot carry 0–10000 bps) or + overloads the variant's meaning for old readers; rejected. diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index c7869d9..bf0db4e 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -8,21 +8,33 @@ responsibilities and the invariants that hold across them. ### market-core -The hub: every binary market lives here as one entry in persistent storage, -keyed by `market_id`. Pricing is a Gnosis-style constant-product FPMM over -internal YES/NO share accounting — no external LP token, no AMM pool -contract per market. `contracts/market-core/src/math.rs` is the entire -pricing engine, pure integer arithmetic, no `Env` dependency, so it's +The hub: every market — binary, categorical, scalar — lives here as one +entry in persistent storage, keyed by `market_id`. Pricing is a Gnosis-style +constant-product FPMM over internal share accounting — no external LP token, +no AMM pool contract per market. `contracts/market-core/src/math.rs` is the +entire pricing engine, pure integer arithmetic, no `Env` dependency, so it's natively unit-testable and mirrored line-for-line in [`model/fpmm_model.py`](../model/fpmm_model.py) and `agents/core/fpmm.ts` for property testing and off-chain quoting. +**Three market kinds, one id space.** A market is binary (the original +`Market`), categorical (#6, 2–8 outcomes), or scalar (#7, a range bet on a +price feed). The XDR of the original `Market` struct is never changed: each +new kind is *side storage* keyed by the same `market_id` (`DataKey::Kind` +records which kind an id is; `market_kind` exposes it), and new enum +variants are appended after the existing tags so deployed readers keep +decoding old values. Readers dispatch on `market_kind` first: `get_market` +returns `MarketNotFound` for a categorical id, whose shape lives in +`get_cat_market`. See [ADR 0005](adr/0005-market-kind-storage-split.md) for +the full argument and the alternatives rejected. + **Solvency invariant** (holds after every state transition — see the test suite for the ones exercised): the contract's own SEP-41 token balance for a market equals `sets_minted + fees_accrued + lp_fees` (`fees_accrued` is the protocol's pending share of trading fees, `lp_fees` the creator's — see the fee split below), and `reserve_yes + Σ(user yes positions) == sets_minted` -(symmetrically for `no`). +(symmetrically for `no`). The same invariant holds for categorical markets +on `CatMarket`. **Fee split and recipient snapshot.** `create_market` snapshots a `FeeTerms { recipient, protocol_share_bps }` side entry per market, so @@ -42,6 +54,30 @@ LP balance (rather than adding fees to the reserves) is deliberate: it does not move `k` or the price and maps directly onto the per-LP fee accumulator of the future multi-LP design ([wave-issue 02](wave-issues/02-multi-lp-liquidity.md)). +**Dynamic fee schedules (#9).** A market can charge a scheduled fee instead +of a flat one. `FeeSchedule { base_bps, max_bps, ramp_secs, skew_mult_bps }` +is snapshotted at creation — from `DataKey::DefaultFeeSchedule` (set by the +admin's `set_default_fee_schedule`, which only affects markets created after +it) or from an explicit `create_market_with_fees`, whose schedule is +validated against the default's `max_bps` cap before a market id is +allocated. The effective fee is recomputed per trade by +`math::effective_fee_bps(schedule, now, close_time, r_yes, r_no)`: +`min(max_bps, base_bps + ramp + skew)`, where `ramp` rises linearly from +`base_bps` to `max_bps` over the last `ramp_secs` before `close_time` (and +stays at `max_bps` afterwards), and `skew` adds `skew_mult_bps` per 1000 bps +the pre-trade YES price sits away from 50/50. Every floor rounds in the +trader's favour; the contract layer bounds `max_bps <= MAX_FEE_BPS`, +`ramp_secs <= MAX_RAMP_SECS` (30 days) and `skew_mult_bps <= MAX_SKEW_MULT_BPS` +(1000), while the pure math only requires `base_bps <= max_bps < BPS`. +`buy` / `sell` / `quote_buy` / `quote_sell`, the `_v2` twins, and the fee +split all use the effective fee; `current_fee_bps` exposes it now, and +`quote_buy_at(market_id, outcome, collateral_in, at_timestamp)` quotes with +the fee that will apply at a *future* timestamp — the client-side latency +hedge described in [frontend-integration.md](frontend-integration.md). +`TradeV2` carries the realised `fee_bps` next to the existing `Trade` event. +Legacy markets have no schedule and keep their flat `fee_bps`; categorical +markets always charge the flat fee. + **Trade bounds and circuit breakers.** Two layers of protection beyond the share-count slippage parameters: @@ -86,14 +122,50 @@ emits `LpTransferred`. Both preserve the solvency invariant by construction; the position mapping is unchanged down to the "remove zero entries" rule. +**Categorical markets (#6).** `create_categorical_market` opens an +n-outcome market (`2 <= n <= 8`, `MAX_MARKET_OUTCOMES`) seeded from +`probs_bps` — one probability per outcome, each within +`MIN_PROB_BPS..=MAX_PROB_BPS` and summing to exactly 10000; the creator/LP +receives the leftover shares of the likelier outcomes. The pool is the +generalized FPMM: reserves `[r_0..r_n]` with the binary invariant +`k = r_yes * r_no` replaced by `k = Π(r_i)`; a `buy_cat` of outcome `i` +moves `r_i` in and renormalizes the other reserves down so the product is +preserved (`sell_cat` is the inverse). The math uses the same iterative +floor/ceil scheme as the binary path — rounding never pays the trader and +`k` never decreases — and is property-tested in all three mirrors. +`get_prices_cat` reports implied probabilities that sum to within `n` of +10000 bps; settlement pays one unit per winning share and a void pays half +per share, exactly like the binary path. Categorical markets keep a flat +snapshotted `fee_bps` (no dynamic schedule) and no price-based breakers; +collateral, pause switch, `FeeTerms` and the `resolution_hash` commitment +are shared with `create_market`. Worst case (`n = 8`) `buy_cat` costs +444,604 instructions / 64,092 B memory / 1,640 write bytes across 5 storage +entries — under 60% of the network limits, asserted by +`cat_eight_outcomes_resource_budget`. Events: `CatTrade`, +`CatMarketResolved`. + +**Scalar markets (#7).** `create_scalar_market` is a binary market +(LONG = YES, SHORT = NO) plus a `ScalarMeta { lower, upper }` display side +entry — trading, fees (dynamic schedules included), breakers and the whole +solvency story are inherited unchanged. Resolution writes +`MarketStatus::ResolvedScalar(payout_long_bps)` (an appended status variant; +0 = full SHORT, 10000 = full LONG) via `resolve_scalar`, and redemption +pays each holder `floor(yes·p / 10000) + floor(no·(10000 − p) / 10000)` — +both legs floor, so total payouts stay within `sets_minted` and a scalar +market cannot overpay. `get_scalar_meta` exposes the bounds; the meta is +display-only and never affects payouts. + **Resolution commitment**: `resolution_hash` is fixed at `create_market` time — see [ADR 0001](adr/0001-resolution-commitment.md) for why. -Lifecycle: `create_market` → `buy`/`sell` (while open; `buy_v2`/`sell_v2` -with bounds) → `resolve` or `void_market` (resolver-only) → `redeem` per -holder, `claim_pool_remainder` for the creator/LP, `sweep_fees` -(permissionless) to the snapshotted fee recipient. Positions and LP -ownership can change hands at any point via `transfer_position` / +Lifecycle: `create_market` / `create_market_with_fees` / +`create_categorical_market` / `create_scalar_market` → `buy`/`sell` +(`buy_v2`/`sell_v2` with bounds; `buy_cat`/`sell_cat`) while open → +`resolve` / `resolve_cat` / `resolve_scalar` or `void_market` +(resolver-only) → `redeem` / `redeem_cat` per holder, +`claim_pool_remainder` / `claim_pool_remainder_cat` for the creator/LP, +`sweep_fees` (permissionless) to the snapshotted fee recipient. Positions +and LP ownership can change hands at any point via `transfer_position` / `transfer_lp`. ### resolver @@ -112,11 +184,26 @@ Independent resolution modes, all ultimately calling signatures to `submit_signatures` in one transaction; the weight threshold starts the same dispute window. `cancel_proposal` moves the market to a new round, invalidating earlier signatures. Message format: ADR 0001. - -Neither mode can be configured until the revealed spec's hash matches the -market's commitment (`register_price_spec` / `register_signer_spec` — see -ADR 0001). A guardian can also `guardian_void_market` an unresolvable -market directly. +- **Categorical signers**: the n-outcome counterpart of Attested. + `register_cat_signer_spec` reveals a `CategoricalSigners` spec (signer + set, `threshold`, `dispute_window`, and the committed `outcome_count`); + signers call `attest_cat(market_id, outcome_index)` — the first index to + reach `threshold` attestations becomes the proposal and opens the dispute + window — and anyone calls `finalize_cat` after the window to resolve the + winning index through market-core's `resolve_cat`. The guardian's + `cancel_proposal` clears a categorical proposal too. +- **Scalar price**: `register_scalar_spec` reveals a `Scalar` spec + (`ScalarPriceConfig`: a Reflector feed plus `lower` / `upper`). After + `close_time`, `resolve_scalar_price` reads the feed — a non-positive + reading is `InvalidPrice` (a broken feed, not a value at the bottom of the + range), with the same staleness window as `resolve_price` — clamps it into + `[lower, upper]` and settles the fractional LONG payout + `(clamped − lower) / (upper − lower)` in bps, flooring toward the pool. + +No rule can be configured until the revealed spec's hash matches the +market's commitment, via the `register_*_spec` entrypoint matching the +committed variant — see ADR 0001. A guardian can also `guardian_void_market` +an unresolvable market directly. #### Bonded disputes (`SignersDisputable`) @@ -167,9 +254,9 @@ off. Holds the agents' pooled USDC and is the *only* path that spends it: an agent calls `agent_create_market` / `agent_buy` / `agent_sell` / -`agent_buy_v2` / `agent_sell_v2`, the treasury checks its own -per-trade/per-market/daily caps, then calls `market-core` itself as the -trader/creator. This means the caps are a real on-chain limit on what the +`agent_buy_v2` / `agent_sell_v2` (and `agent_buy_cat`, the categorical +forwarder), the treasury checks its own per-trade/per-market/daily caps, +then calls `market-core` itself as the trader/creator. This means the caps are a real on-chain limit on what the automated agent can deploy — an agent's own key calling `market-core` directly would just be trading its own separate funds, not bypassing a cap on the treasury's money. The `*_v2` twins forward the same caps plus @@ -247,7 +334,9 @@ one of the seeded `docs/wave-issues/`. is the only part that knows about Soroban, wrapping the generated bindings with a dry-run/live switch. The trader always quotes (`quote_buy`) immediately before submitting and derives non-zero - `min_shares_out` / `limit_price_bps` bounds plus a deadline from it. + `min_shares_out` / `limit_price_bps` bounds plus a deadline from it. In + live mode it reads `get_market` and `current_fee_bps` first, and sizes the + trade under the market's price-impact breaker when one is configured. - **packages/bindings/**: generated TypeScript clients (`scripts/gen-bindings.sh`). ## Known limitations (see docs/STATUS.md for the full, current list) diff --git a/oracledesk-stellar/docs/frontend-integration.md b/oracledesk-stellar/docs/frontend-integration.md index e1faebf..2b9cf50 100644 --- a/oracledesk-stellar/docs/frontend-integration.md +++ b/oracledesk-stellar/docs/frontend-integration.md @@ -100,6 +100,50 @@ const tx = await contract.buy_v2({ `buy` / `sell` remain for backward compatibility but carry no deadline or limit; the share-count bound alone does not stop a stale transaction. +## Dynamic fees: quote at the fee you will pay + +A market may charge a **dynamic fee** (#9): `base_bps` ramping toward +`max_bps` over the last `ramp_secs` before `close_time`, plus a skew +surcharge the further the pre-trade price sits from 50/50. The fee is fixed +inside the trade, so plain `quote_buy` (fee as of *now*) can understate what +a slightly later submission pays — near the close, or with a skewed pool, +the drift can exceed a few bps of slippage. Quote with the timestamp you +expect the transaction to land at instead: + +```ts +const now = BigInt(Math.floor(Date.now() / 1000)); +const at = now + 30n; // expected simulation + signing + inclusion latency +const quote = (await contract.quote_buy_at({ + market_id, outcome, collateral_in, at_timestamp: at, +})).result; +``` + +- `quote_buy_at(market_id, outcome, collateral_in, at_timestamp)` — the + shares a buy would get with the schedule's fee at `at_timestamp`; + it still uses the reserves read at call time, so keep your usual slippage + margin on `min_shares_out`. +- `current_fee_bps(market_id)` — the effective fee right now, for display. +- `fee_schedule(market_id)` — the snapshotted schedule, or `null` for + legacy flat-fee markets. + +`TradeV2` carries the fee actually paid (`fee_bps`) per trade — index that +for "realised fee" displays rather than recomputing it. There is no +`quote_sell_at`, so a sell should either quote as late as possible or pad +`max_shares_in` to absorb the drift. + +## Market kinds + +`get_market(market_id)` reads binary and scalar markets only. Check +`market_kind(market_id)` when the id's kind is not already known: a +categorical id answers `MarketNotFound` there — read +`get_cat_market(market_id)` for reserves/status, `get_prices_cat(market_id)` +for implied probabilities (they sum to ~10000 bps), trade with +`buy_cat` / `sell_cat` (the same share-count bound, no `_v2` variant yet), +and settle with `redeem_cat` / `claim_pool_remainder_cat`. Scalar markets +are binary markets plus `get_scalar_meta(market_id)` (`{ lower, upper }`, +display only); their resolution arrives in `get_market`'s status as +`ResolvedScalar(payout_long_bps)`. + ## Circuit breakers Markets can carry per-market breakers (snapshotted at creation): a maximum diff --git a/oracledesk-stellar/model/fee_fixtures.json b/oracledesk-stellar/model/fee_fixtures.json new file mode 100644 index 0000000..d2a8521 --- /dev/null +++ b/oracledesk-stellar/model/fee_fixtures.json @@ -0,0 +1,15 @@ +[ + [100, 1000, 86400, 500, 1000000, 2000000, 1000, 1000, 100], + [100, 1000, 86400, 500, 1056800, 1100000, 1000, 1000, 550], + [100, 1000, 86400, 500, 1100000, 1100000, 1000, 1000, 1000], + [100, 1000, 0, 500, 1100000, 1100000, 1000, 3000, 1000], + [100, 1000, 0, 500, 0, 31622400, 9900, 10000, 112], + [50, 1000, 10000, 100, 495000, 500000, 3000, 5000, 650], + [50, 1000, 10000, 100, 485000, 500000, 3000, 5000, 175], + [50, 1000, 10000, 100, 600000, 500000, 3000, 5000, 1000], + [200, 900, 0, 100, 400000, 500000, 1000, 3000, 450], + [200, 900, 0, 100, 400000, 500000, 3000, 1000, 450], + [0, 500, 0, 1000, 400000, 500000, 9900, 100, 500], + [0, 500, 0, 1, 400000, 500000, 5000, 5001, 0], + [150, 150, 0, 0, 77, 88, 1000000000000, 1000000000000, 150] +] diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 60815b9..830a10a 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -18,6 +18,8 @@ """ from dataclasses import dataclass +import json +import os from math import isqrt import random @@ -25,6 +27,8 @@ MAX_SETS = 1_000_000_000_000_000 # 1e15 = 100M USDC (7 dp) MIN_PROB_BPS = 100 MAX_PROB_BPS = 9_900 +MAX_RAMP_SECS = 30 * 86_400 +MAX_SKEW_MULT_BPS = 1_000 class MathError(Exception): @@ -205,6 +209,217 @@ def sell_within_limit(collateral_out: int, shares_in: int, limit_bps: int) -> bo return collateral_out * BPS >= shares_in * limit_bps +# --------------------------------------------------------------------------- +# Categorical (N-outcome) FPMM — mirrors math_categorical.rs. +# The binary invariant k = r_yes * r_no generalises to the product of all +# reserves; the iterative ceil accumulation keeps k non-decreasing with at +# most n-1 units of pool-favouring drift per trade (see the rounding proof in +# math_categorical.rs::calc_buy). +# --------------------------------------------------------------------------- + +MAX_MARKET_OUTCOMES = 8 +PRICE_SCALE = 1_000_000_000_000_000_000 # 1e18, same reciprocal scale as math_categorical.rs + + +def _cat_n(reserves) -> int: + n = len(reserves) + if not 2 <= n <= MAX_MARKET_OUTCOMES: + raise MathError("invalid input") + if any(r <= 0 for r in reserves): + raise MathError("invalid input") + return n + + +def _cat_index(n: int, outcome_index: int) -> int: + if not 0 <= outcome_index < n: + raise MathError("invalid input") + return outcome_index + + +@dataclass(frozen=True) +class CatBuyQuote: + shares_out: int + fee: int + net: int + new_reserves: list + + +def cat_calc_buy(reserves, outcome_index: int, collateral_in: int, fee_bps: int) -> CatBuyQuote: + """Buy `collateral_in` of outcome `outcome_index`: mint `net` sets into + every other reserve and shrink the bought reserve so the product of all + reserves holds. Mirrors math_categorical.rs::calc_buy.""" + n = _cat_n(reserves) + j = _cat_index(n, outcome_index) + if collateral_in <= 0: + raise MathError("invalid input") + fee = mul_div_ceil(collateral_in, fee_bps, BPS) + net = collateral_in - fee + if net <= 0: + raise MathError("invalid input") + new_reserves = list(reserves) + acc = reserves[j] + for i in range(n): + if i == j: + continue + new_other = reserves[i] + net + new_reserves[i] = new_other + acc = mul_div_ceil(acc, reserves[i], new_other) + new_reserves[j] = acc + shares_out = reserves[j] + net - acc + if shares_out <= 0: + raise MathError("invalid input") + return CatBuyQuote(shares_out, fee, net, new_reserves) + + +@dataclass(frozen=True) +class CatSellQuote: + shares_in: int + fee: int + gross: int + new_reserves: list + + +def cat_calc_sell(reserves, outcome_index: int, collateral_out: int, fee_bps: int) -> CatSellQuote: + """Sell `outcome_index` for an exact `collateral_out` (Gnosis-style, so + no square root on-chain). Mirrors math_categorical.rs::calc_sell.""" + n = _cat_n(reserves) + j = _cat_index(n, outcome_index) + if collateral_out <= 0 or fee_bps >= BPS: + raise MathError("invalid input") + gross = mul_div_ceil(collateral_out, BPS, BPS - fee_bps) + fee = gross - collateral_out + new_reserves = list(reserves) + acc = reserves[j] + for i in range(n): + if i == j: + continue + if reserves[i] <= gross: + raise MathError("insufficient liquidity") + new_other = reserves[i] - gross + new_reserves[i] = new_other + acc = mul_div_ceil(acc, reserves[i], new_other) + new_reserves[j] = acc + shares_in = acc + gross - reserves[j] + if shares_in <= 0: + raise MathError("invalid input") + return CatSellQuote(shares_in, fee, gross, new_reserves) + + +def cat_prices(reserves) -> list: + """Spot price of every outcome in bps, `p_j = (1/r_j) / Σ(1/r_i)` + evaluated on integer reciprocals. Each price floors, so the n prices sum + to at most BPS and at least BPS - n (display-only; never trade math). + Mirrors math_categorical.rs::prices_bps.""" + n = _cat_n(reserves) + recs = [PRICE_SCALE // reserves[i] for i in range(n)] + total = sum(recs) + if total <= 0: + raise MathError("insufficient liquidity") + return [mul_div_floor(BPS, rec, total) for rec in recs] + + +@dataclass(frozen=True) +class CatSeed: + reserves: list + creator_shares: list + + +def cat_seed(seed: int, probs_bps) -> CatSeed: + """Open a pool at the requested probabilities: each reserve is + `seed * p_min / p_i` (floored), the creator keeps `seed - r_i` of every + outcome. `probs_bps` must have length 2..=MAX_MARKET_OUTCOMES, every + entry >= MIN_PROB_BPS, and the entries must sum to exactly BPS. + Mirrors math_categorical.rs::seed_reserves.""" + n = len(probs_bps) + if not 2 <= n <= MAX_MARKET_OUTCOMES or seed <= 0: + raise MathError("invalid input") + if any(p < MIN_PROB_BPS for p in probs_bps): + raise MathError("invalid input") + if sum(probs_bps) != BPS: + raise MathError("invalid input") + p_min = min(probs_bps) + reserves = [] + creator_shares = [] + for p in probs_bps: + r = mul_div_floor(seed, p_min, p) + if r <= 0: + raise MathError("invalid input") + reserves.append(r) + creator_shares.append(seed - r) + return CatSeed(reserves, creator_shares) + + +def cat_settle_value(resolved, balances) -> int: + """Collateral value of one holder's per-outcome balances once final. + `resolved=w` pays the winning balance; `resolved=None` (void) pays + `floor(Σ balances / n)`. Every complete set is worth exactly 1 and every + rounding floors, so the total paid across holders never exceeds + sets_minted. Mirrors math_categorical.rs::settle_value.""" + n = len(balances) + if not 2 <= n <= MAX_MARKET_OUTCOMES: + raise MathError("invalid input") + if resolved is None: + if any(b < 0 for b in balances): + raise MathError("invalid input") + return sum(balances) // n + _cat_index(n, resolved) + return balances[resolved] + + +# --------------------------------------------------------------------------- +# Scalar (range) market settlement — mirrors math.rs::scalar_payout, which +# backs the Rust `Settlement::Scalar(p)` variant. Kept as its own function +# here because the Python `settle_value(kind, yes, no)` has no slot for the +# resolved payout; `scalar_payout(BPS, ...)` == yes and `(0, ...)` == no. +# --------------------------------------------------------------------------- + + +def scalar_payout(payout_long_bps: int, yes: int, no: int) -> int: + """Collateral value of a (yes, no) share pair when the resolved value + maps to `payout_long_bps` LONG per complete set. Both legs floor + independently, so no holder ever beats their exact rational share.""" + return (yes * payout_long_bps) // BPS + (no * (BPS - payout_long_bps)) // BPS + + +# --------------------------------------------------------------------------- +# Dynamic fee schedule — mirrors math.rs::effective_fee_bps. Deterministic +# integer math shared with the contract and agents/core/fpmm.ts via +# model/fee_fixtures.json; every floor rounds toward the trader (a lower fee +# never harms the pool, whose protection is calc_* rounding). +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class FeeSchedule: + base_bps: int + max_bps: int + ramp_secs: int + skew_mult_bps: int + + +def effective_fee_bps(schedule: FeeSchedule, now: int, close_time: int, r_yes: int, r_no: int) -> int: + """Fee in bps for a trade at `now` with the given pre-trade reserves: + `min(max, base + time_ramp + skew)`. The ramp adds up to `max - base` + linearly over the last `ramp_secs` before close (0 disables it, and any + timestamp at or past close clamps to the full ramp); the skew term adds + `skew_mult_bps` per 1000 bps of distance from a 50/50 price. Guaranteed + `base <= result <= max`, and `max < BPS` is validated so `calc_sell` is + always well-defined.""" + if schedule.base_bps > schedule.max_bps or schedule.max_bps >= BPS: + raise MathError("invalid input") + if schedule.ramp_secs == 0: + t = 0 + else: + if now >= close_time: + into_ramp = schedule.ramp_secs + else: + into_ramp = max(0, schedule.ramp_secs - (close_time - now)) + t = mul_div_floor(into_ramp, schedule.max_bps - schedule.base_bps, schedule.ramp_secs) + p = price_yes_bps(r_yes, r_no) + skew = mul_div_floor(abs(p - BPS // 2), schedule.skew_mult_bps, 1_000) + return min(schedule.max_bps, schedule.base_bps + t + skew) + + # --------------------------------------------------------------------------- # Property tests. No pytest/hypothesis dependency: run directly. # --------------------------------------------------------------------------- @@ -325,6 +540,202 @@ def _check_limits_boundary_and_symmetry(): assert not buy_within_limit(1, 1, 0) +def _check_cat_known_values(): + """Pins the same worked examples as math_categorical.rs's unit test: a + 3-outcome 1000/1000/1000 pool, buying/selling 100 of outcome 0 with no + fee.""" + q = cat_calc_buy([1000, 1000, 1000], 0, 100, 0) + assert (q.shares_out, q.new_reserves) == (272, [828, 1100, 1100]), q + s = cat_calc_sell([1000, 1000, 1000], 0, 100, 0) + assert (s.shares_in, s.new_reserves) == (336, [1236, 900, 900]), s + + # A 2-outcome categorical pool is the binary FPMM: same fee and net, and + # a bought-reserve result within one unit of rounding. + for amt, fee in ((100, 0), (100_000_000, 100), (7, 30)): + bin_q = calc_buy(1_000_000, 3_000_000, amt, fee) + cat_q = cat_calc_buy([1_000_000, 3_000_000], 0, amt, fee) + assert cat_q.fee == bin_q.fee and cat_q.net == bin_q.net + assert cat_q.new_reserves[1] == bin_q.new_reserve_other + assert abs(cat_q.shares_out - bin_q.shares_out) <= 1 + + +def _check_cat_k_never_decreases_and_round_trip_never_profits(trials: int = 2_000, seed: int = 13): + rng = random.Random(seed) + for _ in range(trials): + n = rng.randint(2, MAX_MARKET_OUTCOMES) + reserves = [100 + rng.randint(0, 9_999) for _ in range(n)] + j = rng.randint(0, n - 1) + amt = 100 + rng.randint(0, 99) + fee = rng.randint(0, 299) + + k0 = 1 + for r in reserves: + k0 *= r + q = cat_calc_buy(reserves, j, amt, fee) + k1 = 1 + for r in q.new_reserves: + k1 *= r + assert k1 >= k0, (reserves, j, amt, fee) + for i, r in enumerate(reserves): + if i != j: + assert q.new_reserves[i] == r + q.net + + try: + s = cat_calc_sell(q.new_reserves, j, amt, fee) + except MathError: + continue + assert s.shares_in >= q.shares_out, "free money" + + +def _check_cat_prices_sum_within_n_of_bps(trials: int = 2_000, seed: int = 17): + rng = random.Random(seed) + for _ in range(trials): + n = rng.randint(2, MAX_MARKET_OUTCOMES) + reserves = [1_000 + rng.randint(0, 999_999_999_999) for _ in range(n)] + prices = cat_prices(reserves) + assert BPS - n <= sum(prices) <= BPS, (reserves, prices) + # Equal reserves -> equal prices, each exactly floor(BPS / n). + for n in range(2, MAX_MARKET_OUTCOMES + 1): + prices = cat_prices([7_777] * n) + assert prices == [BPS // n] * n + + +def _check_cat_seed_matches_requested_probs(): + for probs in ([5000, 5000], [6800, 3200], [5000, 3000, 2000], [9000, 500, 400, 100]): + s = cat_seed(1_000_000_000, probs) + prices = cat_prices(s.reserves) + for i, p in enumerate(probs): + assert abs(prices[i] - p) <= 2, (probs, prices) + assert s.reserves[i] + s.creator_shares[i] == 1_000_000_000 + # The least likely outcome is fully held by the pool. + assert s.creator_shares[probs.index(min(probs))] == 0 + # 8 equal outcomes: prices sum to exactly BPS, creator holds nothing. + s = cat_seed(1_000_000_000, [1250] * 8) + assert cat_prices(s.reserves) == [1250] * 8 + assert s.reserves == [1_000_000_000] * 8 + assert s.creator_shares == [0] * 8 + # Validation: sum != BPS, below MIN_PROB_BPS, n out of range, seed 0. + for probs, seed in (([6000, 3000], 1_000), ([9950, 50], 1_000), ([10_000], 1_000), ([1250] * 9, 1_000), ([5000, 5000], 0)): + try: + cat_seed(seed, probs) + except MathError: + continue + raise AssertionError(f"cat_seed accepted {probs} seed={seed}") + + +def _check_cat_settle_never_overpays(trials: int = 1_000, seed: int = 23): + rng = random.Random(seed) + for _ in range(trials): + n = rng.randint(2, MAX_MARKET_OUTCOMES) + sets = 1_000_000 + # Three holders share each outcome's `sets` shares with the pool. + held = [[0] * 3 for _ in range(n)] + for i in range(n): + rest = sets + for h in range(3): + take = rest if h == 2 else rng.randint(0, rest) + held[i][h] = take + rest -= take + w = rng.randint(0, n - 1) + resolved_total = 0 + void_total = 0 + for h in range(3): + balances = [held[i][h] for i in range(n)] + resolved_total += cat_settle_value(w, balances) + void_total += cat_settle_value(None, balances) + assert resolved_total <= sets + assert void_total <= sets, (held, void_total) + # Worked shape checks, mirroring the Rust unit test. + assert cat_settle_value(1, [3, 7, 11]) == 7 + assert cat_settle_value(None, [3, 7, 11]) == 7 # 21 / 3 + assert cat_settle_value(None, [1] * 7) == 1 + + +def _check_scalar_payout_never_overpays(trials: int = 1_000, seed: int = 29): + rng = random.Random(seed) + # Extremes reduce to the binary legs. + assert scalar_payout(BPS, 5, 7) == settle_value("yes", 5, 7) + assert scalar_payout(0, 5, 7) == settle_value("no", 5, 7) + # A complete set is worth exactly 1 mid-range; rounding always floors. + assert scalar_payout(5_000, 1_000, 1_000) == 1_000 + assert scalar_payout(3_333, 1_000, 1_000) == 999 + for _ in range(trials): + sets = rng.randint(1, 1_000_000) + payout = rng.randint(0, BPS) + # Three holders split `sets` YES and `sets` NO shares among them + # (the last holder takes the remainder, so the split is exact). + total = 0 + yes_left = sets + no_left = sets + for h in range(3): + take_y = yes_left if h == 2 else rng.randint(0, yes_left) + take_n = no_left if h == 2 else rng.randint(0, no_left) + yes_left -= take_y + no_left -= take_n + total += scalar_payout(payout, take_y, take_n) + # Both legs floor, so Σ paid <= Σ(yes*p + no*(BPS-p))/BPS == sets. + assert total <= sets, (sets, payout, total) + + +def _check_effective_fee_bounds(trials: int = 2_000, seed: int = 31): + rng = random.Random(seed) + for _ in range(trials): + base = rng.randint(0, 999) + max_bps = rng.randint(base, 1_000) + ramp = rng.randint(0, MAX_RAMP_SECS) + skew_mult = rng.randint(0, MAX_SKEW_MULT_BPS) + close = rng.randint(1_000, 2_000_000_000) + now = rng.randint(0, close + 10_000) + r_yes = rng.randint(1, 10**15) + r_no = rng.randint(1, 10**15) + fee = effective_fee_bps(FeeSchedule(base, max_bps, ramp, skew_mult), now, close, r_yes, r_no) + assert base <= fee <= max_bps <= 1_000, (base, max_bps, ramp, skew_mult, now, close, fee) + # Ramp monotonic in time: the fee never decreases as close approaches. + sched = FeeSchedule(100, 800, 10_000, 0) + prev = -1 + for t in range(2_000_000 - 20_000, 2_000_000 + 5_000 + 1, 137): + fee = effective_fee_bps(sched, t, 2_000_000, 1_000, 1_000) + assert fee >= prev + prev = fee + assert prev == 800 # any timestamp at/past close applies the full ramp + # Zero ramp means "no ramp": the fee is flat all the way to close. + flat = FeeSchedule(100, 800, 0, 0) + assert effective_fee_bps(flat, 0, 2_000_000, 1_000, 1_000) == 100 + assert effective_fee_bps(flat, 2_000_000, 2_000_000, 1_000, 1_000) == 100 + # Skew symmetric around 50/50 for representable ratios. + for q in ((1000, 3000), (900, 1100), (5000, 5000), (1, 4), (7, 3), (1, 1)): + sched_s = FeeSchedule(100, 1_000, 0, 500) + left = effective_fee_bps(sched_s, 0, 1, q[0], q[1]) + right = effective_fee_bps(sched_s, 0, 1, q[1], q[0]) + assert left == right, (q, left, right) + # The pure function only requires base <= max < BPS; the tighter + # MAX_FEE_BPS cap is the contract layer's job (validate_schedule), so a + # (1_000, 1_000) schedule is valid here. + assert effective_fee_bps(FeeSchedule(1_000, 1_000, 0, 0), 0, 1, 1_000, 1_000) == 1_000 + for bad in (FeeSchedule(500, 100, 0, 0), FeeSchedule(0, 10_000, 0, 0)): + try: + effective_fee_bps(bad, 0, 1, 1_000, 1_000) + except MathError: + continue + raise AssertionError(f"effective_fee_bps accepted {bad}") + + +def _check_effective_fee_known_values(): + """Shared cross-language table (also read by math.rs's + effective_fee_matches_fixtures and agents/core/fpmm.test.ts): one row per + fixture, nine numbers — [base_bps, max_bps, ramp_secs, skew_mult_bps, + now, close, r_yes, r_no, expected_fee_bps].""" + fixtures = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fee_fixtures.json") + with open(fixtures, encoding="utf-8") as f: + rows = json.load(f) + assert rows, "fee_fixtures.json has no rows" + for row in rows: + assert len(row) == 9, row + sched = FeeSchedule(row[0], row[1], row[2], row[3]) + fee = effective_fee_bps(sched, row[4], row[5], row[6], row[7]) + assert fee == row[8], (row, fee) + + def run_all(): _check_known_values() _check_seed_opens_at_requested_probability() @@ -333,6 +744,14 @@ def run_all(): _check_void_never_overpays() _check_price_after_maps_both_sides() _check_limits_boundary_and_symmetry() + _check_cat_known_values() + _check_cat_k_never_decreases_and_round_trip_never_profits() + _check_cat_prices_sum_within_n_of_bps() + _check_cat_seed_matches_requested_probs() + _check_cat_settle_never_overpays() + _check_scalar_payout_never_overpays() + _check_effective_fee_bounds() + _check_effective_fee_known_values() print("fpmm_model: all property checks passed") diff --git a/oracledesk-stellar/packages/bindings/market-core/src/index.ts b/oracledesk-stellar/packages/bindings/market-core/src/index.ts index c6fc420..e1b1880 100644 --- a/oracledesk-stellar/packages/bindings/market-core/src/index.ts +++ b/oracledesk-stellar/packages/bindings/market-core/src/index.ts @@ -69,7 +69,11 @@ export const Errors = { /** * Cumulative same-ledger movement would exceed `band_bps`. */ - 22: {message:"PriceBandExceeded"} + 22: {message:"PriceBandExceeded"}, + /** + * `outcome_index` is not a valid outcome of this categorical market. + */ + 28: {message:"InvalidOutcomeIndex"} } @@ -93,7 +97,9 @@ export interface Market { close_time: u64; creator: string; /** - * Snapshot of the fee at creation so later config changes never move it. + * Snapshot of the flat fee at creation so later config changes never + * move it. Charged as-is by categorical markets and by binary markets + * without a `FeeSchedule` side entry (legacy / flat-fee markets). */ fee_bps: u32; /** @@ -130,10 +136,11 @@ sets_minted: i128; status: MarketStatus; } -export type DataKey = {tag: "Admin", values: void} | {tag: "Collateral", values: void} | {tag: "Treasury", values: void} | {tag: "Resolver", values: void} | {tag: "DefaultFeeBps", values: void} | {tag: "ProtocolFeeShareBps", values: void} | {tag: "DefaultBreaker", values: void} | {tag: "Paused", values: void} | {tag: "NextId", values: void} | {tag: "Market", values: readonly [u64]} | {tag: "Position", values: readonly [u64, string]} | {tag: "FeeTerms", values: readonly [u64]} | {tag: "LpFees", values: readonly [u64]} | {tag: "Breaker", values: readonly [u64]} | {tag: "Band", values: readonly [u64]}; +export type DataKey = {tag: "Admin", values: void} | {tag: "Collateral", values: void} | {tag: "Treasury", values: void} | {tag: "Resolver", values: void} | {tag: "DefaultFeeBps", values: void} | {tag: "ProtocolFeeShareBps", values: void} | {tag: "DefaultBreaker", values: void} | {tag: "Paused", values: void} | {tag: "NextId", values: void} | {tag: "Market", values: readonly [u64]} | {tag: "Position", values: readonly [u64, string]} | {tag: "FeeTerms", values: readonly [u64]} | {tag: "LpFees", values: readonly [u64]} | {tag: "Breaker", values: readonly [u64]} | {tag: "Band", values: readonly [u64]} | {tag: "Kind", values: readonly [u64]} | {tag: "CatMarket", values: readonly [u64]} | {tag: "CatPosition", values: readonly [u64, string]} | {tag: "Scalar", values: readonly [u64]} | {tag: "FeeSchedule", values: readonly [u64]} | {tag: "DefaultFeeSchedule", values: void}; export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; + export type Category = {tag: "Crypto", values: void} | {tag: "Macro", values: void} | {tag: "Geopolitics", values: void} | {tag: "Sports", values: void} | {tag: "Culture", values: void} | {tag: "Other", values: void}; @@ -167,6 +174,7 @@ export interface Position { + /** * Temporary (one ledger) band reference for a market. */ @@ -176,8 +184,115 @@ export interface BandState { } +/** + * Categorical market state. Stored under `DataKey::CatMarket(u64)`, sharing + * the id space with binary markets (`DataKey::NextId`), so `get_market` on + * a categorical id is `MarketNotFound` and callers must read `market_kind`. + */ +export interface CatMarket { + category: Category; + close_time: u64; + creator: string; + fee_bps: u32; + fees_accrued: i128; + /** + * Creator has withdrawn the pool remainder after finality. + */ +lp_claimed: boolean; + meta_uri: string; + /** + * Same commitment scheme as the binary `Market` (see there). + */ +question_hash: Buffer; + /** + * One reserve per outcome; `len()` is the outcome count n. + */ +reserves: Array; + resolution_hash: Buffer; + /** + * Complete sets in existence == collateral locked for this market. Each + * outcome's shares (reserve + all holder balances) sum to this. + */ +sets_minted: i128; + status: CatStatus; +} + +/** + * Lifecycle of a categorical market: same three states as `MarketStatus` + * with the winning index instead of a two-way outcome. + */ +export type CatStatus = {tag: "Open", values: void} | {tag: "Resolved", values: readonly [u32]} | {tag: "Void", values: void}; -export type MarketStatus = {tag: "Open", values: void} | {tag: "Resolved", values: readonly [Outcome]} | {tag: "Void", values: void}; + +/** + * Which pool a market id refers to. Absent for markets created before + * categorical markets existed, hence `market_kind` reports `Binary` for + * a stored `Market(u64)` and a `Kind` entry is only written for + * categorical ids (which store a `CatMarket` instead of a `Market`). + */ +export type MarketKind = {tag: "Binary", values: void} | {tag: "Categorical", values: readonly [u32]}; + + +/** + * Display metadata of a scalar market: the `[lower, upper]` range of the + * committed resolution spec, in the feed's raw units. Stored under + * `DataKey::Scalar(u64)` at creation; the resolver's revealed spec is the + * authoritative copy, this entry only tells UIs what to display. + */ +export interface ScalarMeta { + lower: i128; + upper: i128; +} + + +/** + * Per-holder categorical position: one balance per outcome, same order as + * `CatMarket::reserves`. + */ +export interface CatPosition { + balances: Array; +} + + +/** + * Dynamic fee schedule, snapshotted per binary market at creation as a + * side entry (`DataKey::FeeSchedule`) so `Market`'s XDR shape never + * changes. The effective rate of a trade is + * `math::effective_fee_bps(schedule, now, close_time, r_yes, r_no)`: + * a base rate that ramps up towards `max_bps` as `close_time` approaches + * and grows with the pool's distance from 50/50, protecting the single LP + * from informed flow near expiry and from one-sided pools. + * + * Markets created before this existed (and categorical markets, which + * still charge their flat `Market::fee_bps`) have no `FeeSchedule` entry + * and keep a flat fee. The schedule is immutable after creation; only + * `set_default_fee_schedule` (the default for *new* markets) can change. + * Field order is part of the XDR — keep `base_bps`, `max_bps`, + * `ramp_secs`, `skew_mult_bps` stable. + */ +export interface FeeSchedule { + /** + * Fee far from close with the pool at 50/50. + */ +base_bps: u32; + /** + * Hard ceiling for this market, `<= MAX_FEE_BPS`. + */ +max_bps: u32; + /** + * The time ramp starts this many seconds before `close_time` + * (0 = no ramp, `<= math::MAX_RAMP_SECS`). + */ +ramp_secs: u64; + /** + * Extra bps per 1_000 bps of distance from a 5_000 price + * (`<= math::MAX_SKEW_MULT_BPS`). + */ +skew_mult_bps: u32; +} + + +export type MarketStatus = {tag: "Open", values: void} | {tag: "Resolved", values: readonly [Outcome]} | {tag: "Void", values: void} | {tag: "ResolvedScalar", values: readonly [u32]}; /** @@ -204,6 +319,9 @@ max_impact_bps: u32; + + + export interface Client { /** * Construct and simulate a buy transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -247,6 +365,15 @@ export interface Client { */ redeem: ({holder, market_id}: {holder: string, market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a buy_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical-market counterpart of `buy`: spend `collateral_in` on + * `outcome_index` shares. Returns shares received; reverts if fewer than + * `min_shares_out` would be received. No v2 (deadline/limit) variant + * yet — see the categorical notes in `docs/architecture.md`. + */ + buy_cat: ({trader, market_id, outcome_index, collateral_in, min_shares_out}: {trader: string, market_id: u64, outcome_index: u32, collateral_in: i128, min_shares_out: i128}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a resolve transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -264,6 +391,14 @@ export interface Client { */ sell_v2: ({trader, market_id, outcome, collateral_out, max_shares_in, limit_price_bps, deadline}: {trader: string, market_id: u64, outcome: Outcome, collateral_out: i128, max_shares_in: i128, limit_price_bps: u32, deadline: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a sell_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical-market counterpart of `sell`: receive exactly + * `collateral_out` by selling `outcome_index` shares. Returns shares + * sold; reverts if more than `max_shares_in` would be needed. + */ + sell_cat: ({trader, market_id, outcome_index, collateral_out, max_shares_in}: {trader: string, market_id: u64, outcome_index: u32, collateral_out: i128, max_shares_in: i128}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a get_price transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Spot price of `outcome` in bps (0..=10_000). YES + NO ~ 10_000. @@ -272,7 +407,8 @@ export interface Client { /** * Construct and simulate a quote_buy transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - * Shares a `buy` of `collateral_in` would return right now. + * Shares a `buy` of `collateral_in` would return right now, at the + * effective (possibly dynamic) fee for this ledger's timestamp. */ quote_buy: ({market_id, outcome, collateral_in}: {market_id: u64, outcome: Outcome, collateral_in: i128}, options?: MethodOptions) => Promise>> @@ -288,10 +424,19 @@ export interface Client { /** * Construct and simulate a quote_sell transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - * Shares a `sell` for exactly `collateral_out` would consume right now. + * Shares a `sell` for exactly `collateral_out` would consume right now, + * at the effective (possibly dynamic) fee for this ledger's timestamp. */ quote_sell: ({market_id, outcome, collateral_out}: {market_id: u64, outcome: Outcome, collateral_out: i128}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a redeem_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical-market counterpart of `redeem`. Winning balances pay 1:1, + * a void pays `floor(Σ balances / n)` — both floored, so the total paid + * across all holders never exceeds `sets_minted`. + */ + redeem_cat: ({holder, market_id}: {holder: string, market_id: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a set_paused transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -304,6 +449,7 @@ export interface Client { * Sends to the recipient snapshotted in `FeeTerms` at creation. Markets * created before `FeeTerms` existed have none and keep the legacy * behaviour: the `DataKey::Treasury` of the moment of the sweep. + * Works for both market kinds. */ sweep_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -316,6 +462,20 @@ export interface Client { */ would_trip: ({market_id, outcome, collateral_in}: {market_id: u64, outcome: Outcome, collateral_in: i128}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a market_kind transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Whether a market id is a binary (`Market`) or categorical + * (`CatMarket`) market. `MarketNotFound` for unknown ids. + */ + market_kind: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a resolve_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical-market counterpart of `resolve`. Same resolver-only gate + * and close-time rule. + */ + resolve_cat: ({market_id, outcome_index}: {market_id: u64, outcome_index: u32}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a transfer_lp transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Transfers the single LP role (the right to `claim_pool_remainder` and @@ -327,9 +487,18 @@ export interface Client { /** * Construct and simulate a void_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Invalid or unresolvable market. Allowed any time before finality. + * Dispatches on the market kind: binary markets emit `MarketResolved` + * with `None`, categorical markets `CatMarketResolved`. */ void_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a fee_schedule transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * The market's snapshotted dynamic fee schedule (`None` for legacy and + * categorical markets, which charge the flat `Market::fee_bps`). + */ + fee_schedule: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>> + /** * Construct and simulate a get_position transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -340,6 +509,16 @@ export interface Client { */ market_count: (options?: MethodOptions) => Promise> + /** + * Construct and simulate a quote_buy_at transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Shares a `buy` of `collateral_in` would return at ledger timestamp + * `at_timestamp`. Under a ramping schedule the fee rises with time, so + * clients expecting inclusion latency can quote the worst case here + * (see `docs/frontend-integration.md`). No other state is simulated: + * the reserves are the current ones. + */ + quote_buy_at: ({market_id, outcome, collateral_in, at_timestamp}: {market_id: u64, outcome: Outcome, collateral_in: i128, at_timestamp: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a set_resolver transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -365,7 +544,8 @@ export interface Client { * Construct and simulate a claim_lp_fees transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * The creator (the LP) claims their share of accrued trade fees at any * time, including before finality. Returns the amount claimed (0 when - * nothing is pending, mirroring `sweep_fees`). + * nothing is pending, mirroring `sweep_fees`). Works for both market + * kinds (the fee side-entries are keyed by the shared market id). */ claim_lp_fees: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -374,9 +554,18 @@ export interface Client { * Creates a market and seeds the pool. `creator` becomes the sole LP. * `initial_yes_bps` in [100, 9900] sets the opening YES probability; * the creator receives the leftover shares of the likelier outcome. + * When a default dynamic fee schedule is set it is snapshotted for + * this market (see `set_default_fee_schedule`); otherwise the market + * charges the flat `DefaultFeeBps`. */ create_market: ({creator, question_hash, resolution_hash, meta_uri, category, close_time, seed_amount, initial_yes_bps}: {creator: string, question_hash: Buffer, resolution_hash: Buffer, meta_uri: string, category: Category, close_time: u64, seed_amount: i128, initial_yes_bps: u32}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a quote_buy_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Shares a `buy_cat` of `collateral_in` would return right now. + */ + quote_buy_cat: ({market_id, outcome_index, collateral_in}: {market_id: u64, outcome_index: u32, collateral_in: i128}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a breaker_config transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * The market's snapshotted breakers (`None` for legacy markets created @@ -384,6 +573,52 @@ export interface Client { */ breaker_config: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>> + /** + * Construct and simulate a get_cat_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + get_cat_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a get_prices_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Spot prices of all outcomes in bps, same order as the reserves. Each + * price is floored, so they sum to at most BPS and at least BPS - n; + * clients should treat the slack as display rounding, never as a + * mispricing (prices do not enter trade math). + */ + get_prices_cat: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>>> + + /** + * Construct and simulate a resolve_scalar transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Scalar-market counterpart of `resolve`. `payout_long_bps` is the + * final LONG payout in basis points of 1.0 (0..=10_000); the SHORT side + * pays the complement. Same resolver-only gate and close-time rule. + */ + resolve_scalar: ({market_id, payout_long_bps}: {market_id: u64, payout_long_bps: u32}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a current_fee_bps transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * The fee bps this ledger's next trade on `market_id` pays: the + * dynamic effective fee for binary markets with a schedule, the flat + * snapshot for legacy and categorical markets. Under a ramping + * schedule the value changes with the ledger timestamp; use + * `quote_buy_at` to price expected inclusion latency. + */ + current_fee_bps: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a get_scalar_meta transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Display metadata (the staked range) of a scalar market. Absent for + * binary and categorical markets. + */ + get_scalar_meta: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a get_cat_position transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * A holder's categorical position, zero-filled to the market's outcome + * count when they never traded. Errors if the market does not exist. + */ + get_cat_position: ({market_id, holder}: {market_id: u64, holder: string}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a transfer_position transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Moves `amount` shares of `outcome` from `from` to `to`. @@ -412,6 +647,60 @@ export interface Client { */ claim_pool_remainder: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a create_scalar_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Creates a scalar (range) market: the binary pool with LONG = YES and + * SHORT = NO, whose final status carries a fractional payout + * (`MarketStatus::ResolvedScalar`) instead of a side. Everything else + * matches `create_market`; `[lower, upper]` is display metadata stored + * under `DataKey::Scalar` (the authoritative range is inside the + * `ResolutionSpec` committed through `resolution_hash`). + */ + create_scalar_market: ({creator, question_hash, resolution_hash, meta_uri, category, close_time, seed_amount, initial_yes_bps, lower, upper}: {creator: string, question_hash: Buffer, resolution_hash: Buffer, meta_uri: string, category: Category, close_time: u64, seed_amount: i128, initial_yes_bps: u32, lower: i128, upper: i128}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a create_market_with_fees transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * `create_market` with an explicit per-market `FeeSchedule`. Validated + * against `MAX_FEE_BPS` and the current default schedule's `max_bps` + * (creators can undercut protocol policy, never exceed it), then + * snapshotted for this market's lifetime. + */ + create_market_with_fees: ({creator, question_hash, resolution_hash, meta_uri, category, close_time, seed_amount, initial_yes_bps, schedule}: {creator: string, question_hash: Buffer, resolution_hash: Buffer, meta_uri: string, category: Category, close_time: u64, seed_amount: i128, initial_yes_bps: u32, schedule: FeeSchedule}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a claim_pool_remainder_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical-market counterpart of `claim_pool_remainder`: the creator + * (the LP) withdraws the settled pool value plus any unclaimed LP fees. + */ + claim_pool_remainder_cat: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a set_default_fee_schedule transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Sets the dynamic fee schedule snapshotted into binary markets created + * after the call. Bounded by `MAX_FEE_BPS` and the ramp/skew caps; + * existing markets keep their schedule. A creator can pick a stricter + * schedule through `create_market_with_fees`, never a looser one (its + * `max_bps` is capped by this default's `max_bps`). + */ + set_default_fee_schedule: ({schedule}: {schedule: FeeSchedule}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a create_categorical_market transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Creates a categorical market with 2..=8 outcomes and seeds the pool. + * `creator` becomes the sole LP and receives the leftover shares of the + * likelier outcomes. `probs_bps` must have one entry per outcome with + * every probability in `math::MIN_PROB_BPS..=math::MAX_PROB_BPS` and the + * entries summing to exactly BPS. + * + * Collateral, id space, pause switch, fee snapshot and `resolution_hash` + * commitment are shared with `create_market`; the pool is the same FPMM + * with the reserve-product invariant generalised to n outcomes (see + * `math_categorical`). Per-market circuit breakers are price-based and + * binary-only, so categorical markets are not snapshotted with them — + * nor with a `FeeSchedule`: they always charge their flat `fee_bps`. + */ + create_categorical_market: ({creator, question_hash, resolution_hash, meta_uri, category, close_time, seed_amount, probs_bps}: {creator: string, question_hash: Buffer, resolution_hash: Buffer, meta_uri: string, category: Category, close_time: u64, seed_amount: i128, probs_bps: Array}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a set_protocol_fee_share_bps transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Affects markets created after the call; existing markets keep the @@ -439,58 +728,88 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABIAAABHYGJ1eV92MmAgLyBgc2VsbF92MmAgY2FsbGVkIHdpdGggYGVudi5sZWRnZXIoKS50aW1lc3RhbXAoKSA+IGRlYWRsaW5lYC4AAAAAD0RlYWRsaW5lRXhwaXJlZAAAAAATAAAAQmBidXlfdjJgIC8gYHNlbGxfdjJgIGF2ZXJhZ2UgZXhlY3V0aW9uIHByaWNlIHdvcnNlIHRoYW4gdGhlIGxpbWl0LgAAAAAAEkxpbWl0UHJpY2VFeGNlZWRlZAAAAAAAFAAAAMVBIHNpbmdsZSB0cmFkZSB3b3VsZCBtb3ZlIHRoZSBwcmljZSBtb3JlIHRoYW4gYG1heF9pbXBhY3RfYnBzYC4KKEJvdGggdGhpcyBhbmQgdGhlIGJhbmQgcmVqZWN0IHRoZSB3aG9sZSBpbnZvY2F0aW9uLCBzbyB0aGVpciBldmVudHMsCmlmIGFueSwgcm9sbCBiYWNrIHdpdGggaXQg4oCUIHNlZSB0aGUgbm90ZSBvbiBgQnJlYWtlckNvbmZpZ2AuKQAAAAAAABJQcmljZUltcGFjdFRvb0hpZ2gAAAAAABUAAAA4Q3VtdWxhdGl2ZSBzYW1lLWxlZGdlciBtb3ZlbWVudCB3b3VsZCBleGNlZWQgYGJhbmRfYnBzYC4AAAARUHJpY2VCYW5kRXhjZWVkZWQAAAAAAAAW", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAFwAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAABlBhdXNlZAAAAAAAAgAAAAAAAAAKSW52YWxpZEZlZQAAAAAAAwAAAAAAAAANSW52YWxpZEFtb3VudAAAAAAAAAQAAAAAAAAAEkludmFsaWRQcm9iYWJpbGl0eQAAAAAABQAAAAAAAAAQSW52YWxpZENsb3NlVGltZQAAAAYAAAAAAAAADk1hcmtldE5vdEZvdW5kAAAAAAAHAAAAAAAAAA1NYXJrZXROb3RPcGVuAAAAAAAACAAAAAAAAAAMTWFya2V0Q2xvc2VkAAAACQAAAAAAAAAMTm90Q2xvc2VkWWV0AAAACgAAAAAAAAAITm90RmluYWwAAAALAAAAAAAAABBTbGlwcGFnZUV4Y2VlZGVkAAAADAAAAAAAAAASSW5zdWZmaWNpZW50U2hhcmVzAAAAAAANAAAAAAAAABVJbnN1ZmZpY2llbnRMaXF1aWRpdHkAAAAAAAAOAAAAAAAAAA5NYXJrZXRUb29MYXJnZQAAAAAADwAAAAAAAAAPTm90aGluZ1RvUmVkZWVtAAAAABAAAAAAAAAADkFscmVhZHlDbGFpbWVkAAAAAAARAAAAAAAAAAhPdmVyZmxvdwAAABIAAABHYGJ1eV92MmAgLyBgc2VsbF92MmAgY2FsbGVkIHdpdGggYGVudi5sZWRnZXIoKS50aW1lc3RhbXAoKSA+IGRlYWRsaW5lYC4AAAAAD0RlYWRsaW5lRXhwaXJlZAAAAAATAAAAQmBidXlfdjJgIC8gYHNlbGxfdjJgIGF2ZXJhZ2UgZXhlY3V0aW9uIHByaWNlIHdvcnNlIHRoYW4gdGhlIGxpbWl0LgAAAAAAEkxpbWl0UHJpY2VFeGNlZWRlZAAAAAAAFAAAAMVBIHNpbmdsZSB0cmFkZSB3b3VsZCBtb3ZlIHRoZSBwcmljZSBtb3JlIHRoYW4gYG1heF9pbXBhY3RfYnBzYC4KKEJvdGggdGhpcyBhbmQgdGhlIGJhbmQgcmVqZWN0IHRoZSB3aG9sZSBpbnZvY2F0aW9uLCBzbyB0aGVpciBldmVudHMsCmlmIGFueSwgcm9sbCBiYWNrIHdpdGggaXQg4oCUIHNlZSB0aGUgbm90ZSBvbiBgQnJlYWtlckNvbmZpZ2AuKQAAAAAAABJQcmljZUltcGFjdFRvb0hpZ2gAAAAAABUAAAA4Q3VtdWxhdGl2ZSBzYW1lLWxlZGdlciBtb3ZlbWVudCB3b3VsZCBleGNlZWQgYGJhbmRfYnBzYC4AAAARUHJpY2VCYW5kRXhjZWVkZWQAAAAAAAAWAAAAQmBvdXRjb21lX2luZGV4YCBpcyBub3QgYSB2YWxpZCBvdXRjb21lIG9mIHRoaXMgY2F0ZWdvcmljYWwgbWFya2V0LgAAAAAAE0ludmFsaWRPdXRjb21lSW5kZXgAAAAAHA==", "AAAABQAAAAAAAAAAAAAABVRyYWRlAAAAAAAAAQAAAAV0cmFkZQAAAAAAAAkAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAZpc19idXkAAAAAAAEAAAAAAAAAAAAAAApjb2xsYXRlcmFsAAAAAAALAAAAAAAAAAAAAAAGc2hhcmVzAAAAAAALAAAAAAAAAAAAAAADZmVlAAAAAAsAAAAAAAAAAAAAAAtyZXNlcnZlX3llcwAAAAALAAAAAAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAAC", "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAABwAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAA9kZWZhdWx0X2ZlZV9icHMAAAAABAAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAAAAABZwcm90b2NvbF9mZWVfc2hhcmVfYnBzAAAAAAAEAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAACHRyZWFzdXJ5AAAAEw==", - "AAAAAQAAAAAAAAAAAAAABk1hcmtldAAAAAAADQAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAPFVuaXggc2Vjb25kcyAobGVkZ2VyIHRpbWVzdGFtcCkuIFRyYWRpbmcgc3RvcHMgYXQgdGhpcyB0aW1lLgAAAApjbG9zZV90aW1lAAAAAAAGAAAAAAAAAAdjcmVhdG9yAAAAABMAAABGU25hcHNob3Qgb2YgdGhlIGZlZSBhdCBjcmVhdGlvbiBzbyBsYXRlciBjb25maWcgY2hhbmdlcyBuZXZlciBtb3ZlIGl0LgAAAAAAB2ZlZV9icHMAAAAABAAAACZUcmFkaW5nIGZlZXMgd2FpdGluZyBmb3IgYHN3ZWVwX2ZlZXNgLgAAAAAADGZlZXNfYWNjcnVlZAAAAAsAAAA4Q3JlYXRvciBoYXMgd2l0aGRyYXduIHRoZSBwb29sIHJlbWFpbmRlciBhZnRlciBmaW5hbGl0eS4AAAAKbHBfY2xhaW1lZAAAAAAAAQAAAChJUEZTIENJRCAvIFVSSSBvZiB0aGUgcXVlc3Rpb24gbWV0YWRhdGEuAAAACG1ldGFfdXJpAAAAEAAAAIZzaGEyNTYgb2YgdGhlIGNhbm9uaWNhbCBxdWVzdGlvbiBKU09OIHN0b3JlZCBvZmYtY2hhaW4gKElQRlMpLCBzbyBhCmNsaWVudCBjYW4gcHJvdmUgdGhlIHRleHQgaXQgc2hvd3MgaXMgdGhlIHRleHQgdGhhdCB3YXMgY29tbWl0dGVkLgAAAAAADXF1ZXN0aW9uX2hhc2gAAAAAAAPuAAAAIAAAAAAAAAAKcmVzZXJ2ZV9ubwAAAAAACwAAAAAAAAALcmVzZXJ2ZV95ZXMAAAAACwAAAS9Db21taXRtZW50IHRvIGhvdyB0aGlzIG1hcmtldCB3aWxsIGJlIHJlc29sdmVkLCBmaXhlZCBhdCBjcmVhdGlvbiBzbwp0aGUgY3JlYXRvciBjYW5ub3QgcGljayBmYXZvcmFibGUgcmVzb2x1dGlvbiBydWxlcyBhZnRlciBzZWVpbmcKdHJhZGluZyBhY3Rpdml0eS4gVGhlIHJlc29sdmVyIGNvbnRyYWN0IHZlcmlmaWVzIGEgcmV2ZWFsZWQgc3BlYwphZ2FpbnN0IHRoaXMgaGFzaCBiZWZvcmUgY29uZmlndXJpbmcgaXRzZWxmIGZvciB0aGUgbWFya2V0LiBTZWUKYGRvY3MvYWRyLzAwMDEtcmVzb2x1dGlvbi1jb21taXRtZW50Lm1kYC4AAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAEBDb21wbGV0ZSBzZXRzIGluIGV4aXN0ZW5jZSA9PSBjb2xsYXRlcmFsIGxvY2tlZCBmb3IgdGhpcyBtYXJrZXQuAAAAC3NldHNfbWludGVkAAAAAAsAAAAAAAAABnN0YXR1cwAAAAAH0AAAAAxNYXJrZXRTdGF0dXM=", - "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAADwAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAKQ29sbGF0ZXJhbAAAAAAAAAAAAAAAAAAIVHJlYXN1cnkAAAAAAAAAAAAAAAhSZXNvbHZlcgAAAAAAAAAAAAAADURlZmF1bHRGZWVCcHMAAAAAAAAAAAAAAAAAABNQcm90b2NvbEZlZVNoYXJlQnBzAAAAAAAAAAAAAAAADkRlZmF1bHRCcmVha2VyAAAAAAAAAAAAAAAAAAZQYXVzZWQAAAAAAAAAAAAAAAAABk5leHRJZAAAAAAAAQAAAAAAAAAGTWFya2V0AAAAAAABAAAABgAAAAEAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAYAAAATAAAAAQAAAAAAAAAIRmVlVGVybXMAAAABAAAABgAAAAEAAAAAAAAABkxwRmVlcwAAAAAAAQAAAAYAAAABAAAAAAAAAAdCcmVha2VyAAAAAAEAAAAGAAAAAQAAAAAAAAAEQmFuZAAAAAEAAAAG", + "AAAAAQAAAAAAAAAAAAAABk1hcmtldAAAAAAADQAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAPFVuaXggc2Vjb25kcyAobGVkZ2VyIHRpbWVzdGFtcCkuIFRyYWRpbmcgc3RvcHMgYXQgdGhpcyB0aW1lLgAAAApjbG9zZV90aW1lAAAAAAAGAAAAAAAAAAdjcmVhdG9yAAAAABMAAADGU25hcHNob3Qgb2YgdGhlIGZsYXQgZmVlIGF0IGNyZWF0aW9uIHNvIGxhdGVyIGNvbmZpZyBjaGFuZ2VzIG5ldmVyCm1vdmUgaXQuIENoYXJnZWQgYXMtaXMgYnkgY2F0ZWdvcmljYWwgbWFya2V0cyBhbmQgYnkgYmluYXJ5IG1hcmtldHMKd2l0aG91dCBhIGBGZWVTY2hlZHVsZWAgc2lkZSBlbnRyeSAobGVnYWN5IC8gZmxhdC1mZWUgbWFya2V0cykuAAAAAAAHZmVlX2JwcwAAAAAEAAAAJlRyYWRpbmcgZmVlcyB3YWl0aW5nIGZvciBgc3dlZXBfZmVlc2AuAAAAAAAMZmVlc19hY2NydWVkAAAACwAAADhDcmVhdG9yIGhhcyB3aXRoZHJhd24gdGhlIHBvb2wgcmVtYWluZGVyIGFmdGVyIGZpbmFsaXR5LgAAAApscF9jbGFpbWVkAAAAAAABAAAAKElQRlMgQ0lEIC8gVVJJIG9mIHRoZSBxdWVzdGlvbiBtZXRhZGF0YS4AAAAIbWV0YV91cmkAAAAQAAAAhnNoYTI1NiBvZiB0aGUgY2Fub25pY2FsIHF1ZXN0aW9uIEpTT04gc3RvcmVkIG9mZi1jaGFpbiAoSVBGUyksIHNvIGEKY2xpZW50IGNhbiBwcm92ZSB0aGUgdGV4dCBpdCBzaG93cyBpcyB0aGUgdGV4dCB0aGF0IHdhcyBjb21taXR0ZWQuAAAAAAANcXVlc3Rpb25faGFzaAAAAAAAA+4AAAAgAAAAAAAAAApyZXNlcnZlX25vAAAAAAALAAAAAAAAAAtyZXNlcnZlX3llcwAAAAALAAABL0NvbW1pdG1lbnQgdG8gaG93IHRoaXMgbWFya2V0IHdpbGwgYmUgcmVzb2x2ZWQsIGZpeGVkIGF0IGNyZWF0aW9uIHNvCnRoZSBjcmVhdG9yIGNhbm5vdCBwaWNrIGZhdm9yYWJsZSByZXNvbHV0aW9uIHJ1bGVzIGFmdGVyIHNlZWluZwp0cmFkaW5nIGFjdGl2aXR5LiBUaGUgcmVzb2x2ZXIgY29udHJhY3QgdmVyaWZpZXMgYSByZXZlYWxlZCBzcGVjCmFnYWluc3QgdGhpcyBoYXNoIGJlZm9yZSBjb25maWd1cmluZyBpdHNlbGYgZm9yIHRoZSBtYXJrZXQuIFNlZQpgZG9jcy9hZHIvMDAwMS1yZXNvbHV0aW9uLWNvbW1pdG1lbnQubWRgLgAAAAAPcmVzb2x1dGlvbl9oYXNoAAAAA+4AAAAgAAAAQENvbXBsZXRlIHNldHMgaW4gZXhpc3RlbmNlID09IGNvbGxhdGVyYWwgbG9ja2VkIGZvciB0aGlzIG1hcmtldC4AAAALc2V0c19taW50ZWQAAAAACwAAAAAAAAAGc3RhdHVzAAAAAAfQAAAADE1hcmtldFN0YXR1cw==", + "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAAFQAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAAKQ29sbGF0ZXJhbAAAAAAAAAAAAAAAAAAIVHJlYXN1cnkAAAAAAAAAAAAAAAhSZXNvbHZlcgAAAAAAAAAAAAAADURlZmF1bHRGZWVCcHMAAAAAAAAAAAAAAAAAABNQcm90b2NvbEZlZVNoYXJlQnBzAAAAAAAAAAAAAAAADkRlZmF1bHRCcmVha2VyAAAAAAAAAAAAAAAAAAZQYXVzZWQAAAAAAAAAAAAAAAAABk5leHRJZAAAAAAAAQAAAAAAAAAGTWFya2V0AAAAAAABAAAABgAAAAEAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAYAAAATAAAAAQAAAAAAAAAIRmVlVGVybXMAAAABAAAABgAAAAEAAAAAAAAABkxwRmVlcwAAAAAAAQAAAAYAAAABAAAAAAAAAAdCcmVha2VyAAAAAAEAAAAGAAAAAQAAAAAAAAAEQmFuZAAAAAEAAAAGAAAAAQAAADpPbmx5IHdyaXR0ZW4gZm9yIGNhdGVnb3JpY2FsIG1hcmtldHM7IGFic2VudCBtZWFucyBiaW5hcnkuAAAAAAAES2luZAAAAAEAAAAGAAAAAQAAAAAAAAAJQ2F0TWFya2V0AAAAAAAAAQAAAAYAAAABAAAAAAAAAAtDYXRQb3NpdGlvbgAAAAACAAAABgAAABMAAAABAAAAckRpc3BsYXkgbWV0YWRhdGEgb2YgYSBzY2FsYXIgbWFya2V0LCB3cml0dGVuIGJ5CmBjcmVhdGVfc2NhbGFyX21hcmtldGA7IGFic2VudCBmb3IgYmluYXJ5IGFuZCBjYXRlZ29yaWNhbCBtYXJrZXRzLgAAAAAABlNjYWxhcgAAAAAAAQAAAAYAAAABAAAAgUR5bmFtaWMgZmVlIHNjaGVkdWxlIHNuYXBzaG90dGVkIGF0IGNyZWF0aW9uOyBhYnNlbnQgZm9yIGxlZ2FjeSBhbmQKY2F0ZWdvcmljYWwgbWFya2V0cywgd2hpY2ggY2hhcmdlIHRoZSBmbGF0IGBNYXJrZXQ6OmZlZV9icHNgLgAAAAAAAAtGZWVTY2hlZHVsZQAAAAABAAAABgAAAAAAAACCSW5zdGFuY2Ugc3RvcmFnZSwgYnV0IGFwcGVuZGVkIGhlcmUgYW55d2F5OiB0aGUgWERSIHRhZyBpcyB0aGUKdmFyaWFudCBwb3NpdGlvbiwgc28gZXZlbiBpbnN0YW5jZSBrZXlzIG11c3Qgb25seSBldmVyIGJlIGFwcGVuZGVkLgAAAAAAEkRlZmF1bHRGZWVTY2hlZHVsZQAA", "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", + "AAAABQAAAZJTYW1lIHRyYWRlIGFzIGBUcmFkZWAgcGx1cyB0aGUgcmVhbGlzZWQgZmVlIHJhdGUuIE5ldyBldmVudCByYXRoZXIgdGhhbgphIG5ldyBmaWVsZCBzbyB0aGUgZXhpc3RpbmcgYFRyYWRlYCBYRFIgc3RheXMgc3RhYmxlIGZvciBpbmRleGVycyAodGhlCmBGZWVzU3dlcHRUb2AgY29udmVudGlvbik7IGJvdGggYXJlIGVtaXR0ZWQgb24gZXZlcnkgYmluYXJ5IHRyYWRlLgpgZmVlX2Jwc2AgaXMgdGhlIGVmZmVjdGl2ZSBkeW5hbWljIHJhdGUgYXBwbGllZCwgYWxyZWFkeSBzbmFwc2hvdHRlZAppbnRvIGBmZWVgIOKAlCBpbmRleGVycyBjYW4gcmVjb3ZlciBpdCBmcm9tIGBmZWUgLyBjb2xsYXRlcmFsYCBidXQgbm90CmV4YWN0bHksIGJlY2F1c2UgYGZlZWAgY2FycmllcyB0aGUgY2VpbGluZyByb3VuZGluZy4AAAAAAAAAAAAHVHJhZGVWMgAAAAABAAAACHRyYWRlX3YyAAAACgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABnRyYWRlcgAAAAAAEwAAAAEAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAAAAAABmlzX2J1eQAAAAAAAQAAAAAAAAAAAAAACmNvbGxhdGVyYWwAAAAAAAsAAAAAAAAAAAAAAAZzaGFyZXMAAAAAAAsAAAAAAAAAAAAAAANmZWUAAAAACwAAAAAAAAAAAAAAB2ZlZV9icHMAAAAABAAAAAAAAAAAAAAAC3Jlc2VydmVfeWVzAAAAAAsAAAAAAAAAAAAAAApyZXNlcnZlX25vAAAAAAALAAAAAAAAAAI=", "AAAAAgAAAAAAAAAAAAAACENhdGVnb3J5AAAABgAAAAAAAAAAAAAABkNyeXB0bwAAAAAAAAAAAAAAAAAFTWFjcm8AAAAAAAAAAAAAAAAAAAtHZW9wb2xpdGljcwAAAAAAAAAAAAAAAAZTcG9ydHMAAAAAAAAAAAAAAAAAB0N1bHR1cmUAAAAAAAAAAAAAAAAFT3RoZXIAAAA=", "AAAAAQAAAahGZWUgZGVzdGluYXRpb24gYW5kIHNwbGl0LCBzbmFwc2hvdHRlZCBwZXIgbWFya2V0IGF0IGNyZWF0aW9uIGFzIGEgc2lkZQplbnRyeSAoYERhdGFLZXk6OkZlZVRlcm1zYCkgc28gYE1hcmtldGAncyBYRFIgc2hhcGUgbmV2ZXIgY2hhbmdlcy4KCk1hcmtldHMgY3JlYXRlZCBiZWZvcmUgdGhpcyBlbnRyeSBleGlzdGVkIGhhdmUgbm8gYEZlZVRlcm1zYCBhbmQga2VlcCB0aGUKbGVnYWN5IGJlaGF2aW91cjogYHN3ZWVwX2ZlZXNgIHBheXMgd2hhdGV2ZXIgYERhdGFLZXk6OlRyZWFzdXJ5YCBob2xkcyBhdApzd2VlcCB0aW1lLCB3aXRoIHRoZSBwcm90b2NvbCBrZWVwaW5nIDEwMCUgb2YgdGhlIGZlZS4gVGhhdCBmYWxsYmFjayBpcwpkb2N1bWVudGVkIGluIGBzd2VlcF9mZWVzYCBhbmQgbWFrZXMgdGhlIHVwZ3JhZGUgbWlncmF0aW9uLWZyZWUuAAAAAAAAAAhGZWVUZXJtcwAAAAIAAACGUHJvdG9jb2wncyBzaGFyZSBvZiBlYWNoIHRyYWRlIGZlZSBpbiBicHM7IHRoZSByZXN0IGlzIHRoZSBMUCdzLgowLi49QlBTLiBBdCBCUFMgKHRoZSBkZWZhdWx0KSB0aGUgc3BsaXQgaXMgZXhhY3RseSB0b2RheSdzIGJlaGF2aW91ci4AAAAAABJwcm90b2NvbF9zaGFyZV9icHMAAAAAAAQAAAB7V2hlcmUgdGhlIHByb3RvY29sJ3MgZmVlIHNoYXJlIGlzIHN3ZXB0LiBGcm96ZW4gYXQgY3JlYXRpb246IGxhdGVyCmBzZXRfdHJlYXN1cnlgIGNhbGxzIG5ldmVyIHJlZGlyZWN0IGZlZXMgYWxyZWFkeSBlYXJuZWQuAAAAAAlyZWNpcGllbnQAAAAAAAAT", "AAAAAQAAAAAAAAAAAAAACFBvc2l0aW9uAAAAAgAAAAAAAAACbm8AAAAAAAsAAAAAAAAAA3llcwAAAAAL", + "AAAABQAAAAAAAAAAAAAACENhdFRyYWRlAAAAAQAAAAljYXRfdHJhZGUAAAAAAAAIAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAQAAAAAAAAANb3V0Y29tZV9pbmRleAAAAAAAAAQAAAAAAAAAAAAAAAZpc19idXkAAAAAAAEAAAAAAAAAAAAAAApjb2xsYXRlcmFsAAAAAAALAAAAAAAAAAAAAAAGc2hhcmVzAAAAAAALAAAAAAAAAAAAAAADZmVlAAAAAAsAAAAAAAAAOVBvc3QtdHJhZGUgcmVzZXJ2ZXMsIHNhbWUgb3JkZXIgYXMgYENhdE1hcmtldDo6cmVzZXJ2ZXNgLgAAAAAAAAhyZXNlcnZlcwAAA+oAAAALAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAACFJlZGVlbWVkAAAAAQAAAAhyZWRlZW1lZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAZob2xkZXIAAAAAABMAAAABAAAAAAAAAAZwYXlvdXQAAAAAAAsAAAAAAAAAAg==", "AAAAAQAAADNUZW1wb3JhcnkgKG9uZSBsZWRnZXIpIGJhbmQgcmVmZXJlbmNlIGZvciBhIG1hcmtldC4AAAAAAAAAAAlCYW5kU3RhdGUAAAAAAAACAAAAAAAAAApsZWRnZXJfc2VxAAAAAAAEAAAAAAAAAA1yZWZfcHJpY2VfYnBzAAAAAAAABA==", + "AAAAAQAAANxDYXRlZ29yaWNhbCBtYXJrZXQgc3RhdGUuIFN0b3JlZCB1bmRlciBgRGF0YUtleTo6Q2F0TWFya2V0KHU2NClgLCBzaGFyaW5nCnRoZSBpZCBzcGFjZSB3aXRoIGJpbmFyeSBtYXJrZXRzIChgRGF0YUtleTo6TmV4dElkYCksIHNvIGBnZXRfbWFya2V0YCBvbgphIGNhdGVnb3JpY2FsIGlkIGlzIGBNYXJrZXROb3RGb3VuZGAgYW5kIGNhbGxlcnMgbXVzdCByZWFkIGBtYXJrZXRfa2luZGAuAAAAAAAAAAlDYXRNYXJrZXQAAAAAAAAMAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAB2NyZWF0b3IAAAAAEwAAAAAAAAAHZmVlX2JwcwAAAAAEAAAAAAAAAAxmZWVzX2FjY3J1ZWQAAAALAAAAOENyZWF0b3IgaGFzIHdpdGhkcmF3biB0aGUgcG9vbCByZW1haW5kZXIgYWZ0ZXIgZmluYWxpdHkuAAAACmxwX2NsYWltZWQAAAAAAAEAAAAAAAAACG1ldGFfdXJpAAAAEAAAADpTYW1lIGNvbW1pdG1lbnQgc2NoZW1lIGFzIHRoZSBiaW5hcnkgYE1hcmtldGAgKHNlZSB0aGVyZSkuAAAAAAANcXVlc3Rpb25faGFzaAAAAAAAA+4AAAAgAAAAOE9uZSByZXNlcnZlIHBlciBvdXRjb21lOyBgbGVuKClgIGlzIHRoZSBvdXRjb21lIGNvdW50IG4uAAAACHJlc2VydmVzAAAD6gAAAAsAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAINDb21wbGV0ZSBzZXRzIGluIGV4aXN0ZW5jZSA9PSBjb2xsYXRlcmFsIGxvY2tlZCBmb3IgdGhpcyBtYXJrZXQuIEVhY2gKb3V0Y29tZSdzIHNoYXJlcyAocmVzZXJ2ZSArIGFsbCBob2xkZXIgYmFsYW5jZXMpIHN1bSB0byB0aGlzLgAAAAALc2V0c19taW50ZWQAAAAACwAAAAAAAAAGc3RhdHVzAAAAAAfQAAAACUNhdFN0YXR1cwAAAA==", + "AAAAAgAAAHtMaWZlY3ljbGUgb2YgYSBjYXRlZ29yaWNhbCBtYXJrZXQ6IHNhbWUgdGhyZWUgc3RhdGVzIGFzIGBNYXJrZXRTdGF0dXNgCndpdGggdGhlIHdpbm5pbmcgaW5kZXggaW5zdGVhZCBvZiBhIHR3by13YXkgb3V0Y29tZS4AAAAAAAAAAAlDYXRTdGF0dXMAAAAAAAADAAAAAAAAAAAAAAAET3BlbgAAAAEAAAAhRmluYWwuIFdpbm5pbmcgc2hhcmVzIHJlZGVlbSAxOjEuAAAAAAAACFJlc29sdmVkAAAAAQAAAAQAAAAAAAAAK0ludmFsaWQgbWFya2V0LiBFdmVyeSBzaGFyZSByZWRlZW1zIGF0IDEvbi4AAAAABFZvaWQ=", "AAAABQAAAAAAAAAAAAAACUZlZXNTd2VwdAAAAAAAAAEAAAAKZmVlc19zd2VwdAAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAC", + "AAAAAgAAAQpXaGljaCBwb29sIGEgbWFya2V0IGlkIHJlZmVycyB0by4gQWJzZW50IGZvciBtYXJrZXRzIGNyZWF0ZWQgYmVmb3JlCmNhdGVnb3JpY2FsIG1hcmtldHMgZXhpc3RlZCwgaGVuY2UgYG1hcmtldF9raW5kYCByZXBvcnRzIGBCaW5hcnlgIGZvcgphIHN0b3JlZCBgTWFya2V0KHU2NClgIGFuZCBhIGBLaW5kYCBlbnRyeSBpcyBvbmx5IHdyaXR0ZW4gZm9yCmNhdGVnb3JpY2FsIGlkcyAod2hpY2ggc3RvcmUgYSBgQ2F0TWFya2V0YCBpbnN0ZWFkIG9mIGEgYE1hcmtldGApLgAAAAAAAAAAAApNYXJrZXRLaW5kAAAAAAACAAAAAAAAAAAAAAAGQmluYXJ5AAAAAAABAAAAJ091dGNvbWUgY291bnQsIDIuLj1NQVhfTUFSS0VUX09VVENPTUVTLgAAAAALQ2F0ZWdvcmljYWwAAAAAAQAAAAQ=", + "AAAAAQAAAQ5EaXNwbGF5IG1ldGFkYXRhIG9mIGEgc2NhbGFyIG1hcmtldDogdGhlIGBbbG93ZXIsIHVwcGVyXWAgcmFuZ2Ugb2YgdGhlCmNvbW1pdHRlZCByZXNvbHV0aW9uIHNwZWMsIGluIHRoZSBmZWVkJ3MgcmF3IHVuaXRzLiBTdG9yZWQgdW5kZXIKYERhdGFLZXk6OlNjYWxhcih1NjQpYCBhdCBjcmVhdGlvbjsgdGhlIHJlc29sdmVyJ3MgcmV2ZWFsZWQgc3BlYyBpcyB0aGUKYXV0aG9yaXRhdGl2ZSBjb3B5LCB0aGlzIGVudHJ5IG9ubHkgdGVsbHMgVUlzIHdoYXQgdG8gZGlzcGxheS4AAAAAAAAAAAAKU2NhbGFyTWV0YQAAAAAAAgAAAAAAAAAFbG93ZXIAAAAAAAALAAAAAAAAAAV1cHBlcgAAAAAAAAs=", + "AAAAAQAAAF5QZXItaG9sZGVyIGNhdGVnb3JpY2FsIHBvc2l0aW9uOiBvbmUgYmFsYW5jZSBwZXIgb3V0Y29tZSwgc2FtZSBvcmRlciBhcwpgQ2F0TWFya2V0OjpyZXNlcnZlc2AuAAAAAAAAAAAAC0NhdFBvc2l0aW9uAAAAAAEAAAAAAAAACGJhbGFuY2VzAAAD6gAAAAs=", + "AAAAAQAAAzZEeW5hbWljIGZlZSBzY2hlZHVsZSwgc25hcHNob3R0ZWQgcGVyIGJpbmFyeSBtYXJrZXQgYXQgY3JlYXRpb24gYXMgYQpzaWRlIGVudHJ5IChgRGF0YUtleTo6RmVlU2NoZWR1bGVgKSBzbyBgTWFya2V0YCdzIFhEUiBzaGFwZSBuZXZlcgpjaGFuZ2VzLiBUaGUgZWZmZWN0aXZlIHJhdGUgb2YgYSB0cmFkZSBpcwpgbWF0aDo6ZWZmZWN0aXZlX2ZlZV9icHMoc2NoZWR1bGUsIG5vdywgY2xvc2VfdGltZSwgcl95ZXMsIHJfbm8pYDoKYSBiYXNlIHJhdGUgdGhhdCByYW1wcyB1cCB0b3dhcmRzIGBtYXhfYnBzYCBhcyBgY2xvc2VfdGltZWAgYXBwcm9hY2hlcwphbmQgZ3Jvd3Mgd2l0aCB0aGUgcG9vbCdzIGRpc3RhbmNlIGZyb20gNTAvNTAsIHByb3RlY3RpbmcgdGhlIHNpbmdsZSBMUApmcm9tIGluZm9ybWVkIGZsb3cgbmVhciBleHBpcnkgYW5kIGZyb20gb25lLXNpZGVkIHBvb2xzLgoKTWFya2V0cyBjcmVhdGVkIGJlZm9yZSB0aGlzIGV4aXN0ZWQgKGFuZCBjYXRlZ29yaWNhbCBtYXJrZXRzLCB3aGljaApzdGlsbCBjaGFyZ2UgdGhlaXIgZmxhdCBgTWFya2V0OjpmZWVfYnBzYCkgaGF2ZSBubyBgRmVlU2NoZWR1bGVgIGVudHJ5CmFuZCBrZWVwIGEgZmxhdCBmZWUuIFRoZSBzY2hlZHVsZSBpcyBpbW11dGFibGUgYWZ0ZXIgY3JlYXRpb247IG9ubHkKYHNldF9kZWZhdWx0X2ZlZV9zY2hlZHVsZWAgKHRoZSBkZWZhdWx0IGZvciAqbmV3KiBtYXJrZXRzKSBjYW4gY2hhbmdlLgpGaWVsZCBvcmRlciBpcyBwYXJ0IG9mIHRoZSBYRFIg4oCUIGtlZXAgYGJhc2VfYnBzYCwgYG1heF9icHNgLApgcmFtcF9zZWNzYCwgYHNrZXdfbXVsdF9icHNgIHN0YWJsZS4AAAAAAAAAAAALRmVlU2NoZWR1bGUAAAAABAAAACpGZWUgZmFyIGZyb20gY2xvc2Ugd2l0aCB0aGUgcG9vbCBhdCA1MC81MC4AAAAAAAhiYXNlX2JwcwAAAAQAAAAvSGFyZCBjZWlsaW5nIGZvciB0aGlzIG1hcmtldCwgYDw9IE1BWF9GRUVfQlBTYC4AAAAAB21heF9icHMAAAAABAAAAGNUaGUgdGltZSByYW1wIHN0YXJ0cyB0aGlzIG1hbnkgc2Vjb25kcyBiZWZvcmUgYGNsb3NlX3RpbWVgCigwID0gbm8gcmFtcCwgYDw9IG1hdGg6Ok1BWF9SQU1QX1NFQ1NgKS4AAAAACXJhbXBfc2VjcwAAAAAAAAYAAABWRXh0cmEgYnBzIHBlciAxXzAwMCBicHMgb2YgZGlzdGFuY2UgZnJvbSBhIDVfMDAwIHByaWNlCihgPD0gbWF0aDo6TUFYX1NLRVdfTVVMVF9CUFNgKS4AAAAAAA1za2V3X211bHRfYnBzAAAAAAAABA==", "AAAABQAAALFTYW1lIHN3ZWVwIGFzIGBGZWVzU3dlcHRgIHBsdXMgdGhlIGFjdHVhbCByZWNpcGllbnQuIE5ldyBldmVudCByYXRoZXIKdGhhbiBuZXcgZmllbGRzIHNvIHRoZSBleGlzdGluZyBgRmVlc1N3ZXB0YCBYRFIgc3RheXMgc3RhYmxlIGZvcgppbmRleGVyczsgYm90aCBhcmUgZW1pdHRlZCBvbiBldmVyeSBzd2VlcC4AAAAAAAAAAAAAC0ZlZXNTd2VwdFRvAAAAAAEAAAANZmVlc19zd2VwdF90bwAAAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAlyZWNpcGllbnQAAAAAAAATAAAAAAAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", - "AAAAAgAAAAAAAAAAAAAADE1hcmtldFN0YXR1cwAAAAMAAAAAAAAAQlRyYWRpbmcgYWxsb3dlZCB1bnRpbCBgY2xvc2VfdGltZWAsIHRoZW4gd2FpdGluZyBmb3IgdGhlIHJlc29sdmVyLgAAAAAABE9wZW4AAAABAAAAIUZpbmFsLiBXaW5uaW5nIHNoYXJlcyByZWRlZW0gMToxLgAAAAAAAAhSZXNvbHZlZAAAAAEAAAfQAAAAB091dGNvbWUAAAAAAAAAACtJbnZhbGlkIG1hcmtldC4gRXZlcnkgc2hhcmUgcmVkZWVtcyBhdCAwLjUuAAAAAARWb2lk", + "AAAAAgAAAAAAAAAAAAAADE1hcmtldFN0YXR1cwAAAAQAAAAAAAAAQlRyYWRpbmcgYWxsb3dlZCB1bnRpbCBgY2xvc2VfdGltZWAsIHRoZW4gd2FpdGluZyBmb3IgdGhlIHJlc29sdmVyLgAAAAAABE9wZW4AAAABAAAAIUZpbmFsLiBXaW5uaW5nIHNoYXJlcyByZWRlZW0gMToxLgAAAAAAAAhSZXNvbHZlZAAAAAEAAAfQAAAAB091dGNvbWUAAAAAAAAAACtJbnZhbGlkIG1hcmtldC4gRXZlcnkgc2hhcmUgcmVkZWVtcyBhdCAwLjUuAAAAAARWb2lkAAAAAQAAAQdTY2FsYXIgKHJhbmdlKSBtYXJrZXQsIGZpbmFsOiBMT05HIHNoYXJlcyBwYXkgYHBheW91dF9sb25nX2JwcyAvCjEwXzAwMGAgYW5kIFNIT1JUIHNoYXJlcyB0aGUgY29tcGxlbWVudC4gQXBwZW5kZWQgbGFzdCBzbyBzdG9yZWQKYFJlc29sdmVkYC9gVm9pZGAgdmFsdWVzIGtlZXAgdGhlaXIgZW5jb2Rpbmc7IHJlYWRlcnMgY29tcGlsZWQKYWdhaW5zdCBvbGRlciBiaW5kaW5ncyBmYWlsIHRvIGRlY29kZSB0aGlzIG9uZSAoc2VlIHRoZSByZWxlYXNlIG5vdGUpLgAAAAAOUmVzb2x2ZWRTY2FsYXIAAAAAAAEAAAAE", "AAAAAQAAAMJQZXItbWFya2V0IGNpcmN1aXQgYnJlYWtlcnMsIHNuYXBzaG90dGVkIGF0IGNyZWF0aW9uIGZyb20KYERhdGFLZXk6OkRlZmF1bHRCcmVha2VyYC4gMCBkaXNhYmxlcyBlYWNoIGNoZWNrLiBBIG1hcmtldCB3aXRob3V0IGEKYEJyZWFrZXJgIGVudHJ5IChjcmVhdGVkIGJlZm9yZSBicmVha2VycyBleGlzdGVkKSBpcyBuZXZlciBjaGVja2VkLgAAAAAAAAAAAA1CcmVha2VyQ29uZmlnAAAAAAAAAgAAAERNYXhpbXVtIHxwb3N0LXByaWNlIC0gbGVkZ2VyLW9wZW4gcHJpY2V8IGN1bXVsYXRpdmUgbW92ZW1lbnQgaW4gYnBzLgAAAAhiYW5kX2JwcwAAAAQAAABATWF4aW11bSB8cG9zdC1wcmljZSAtIHByZS1wcmljZXwgYSBzaW5nbGUgdHJhZGUgbWF5IG1vdmUgaW4gYnBzLgAAAA5tYXhfaW1wYWN0X2JwcwAAAAAABA==", "AAAABQAAAAAAAAAAAAAADUxwRmVlc0NsYWltZWQAAAAAAAABAAAAD2xwX2ZlZXNfY2xhaW1lZAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAADUxwVHJhbnNmZXJyZWQAAAAAAAABAAAADmxwX3RyYW5zZmVycmVkAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAEZnJvbQAAABMAAAAAAAAAAAAAAAJ0bwAAAAAAEwAAAAAAAAAC", "AAAABQAAAAAAAAAAAAAADU1hcmtldENyZWF0ZWQAAAAAAAABAAAADm1hcmtldF9jcmVhdGVkAAAAAAAGAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAAAAAAAIY2F0ZWdvcnkAAAfQAAAACENhdGVnb3J5AAAAAAAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAAAAAAA9pbml0aWFsX3llc19icHMAAAAABAAAAAAAAAAC", "AAAABQAAAAAAAAAAAAAADk1hcmtldFJlc29sdmVkAAAAAAABAAAAD21hcmtldF9yZXNvbHZlZAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAACFOb25lIG1lYW5zIHRoZSBtYXJrZXQgd2FzIHZvaWRlZC4AAAAAAAAHb3V0Y29tZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", "AAAABQAAAAAAAAAAAAAAD1RyZWFzdXJ5Q2hhbmdlZAAAAAABAAAAEHRyZWFzdXJ5X2NoYW5nZWQAAAACAAAAAAAAAANvbGQAAAAAEwAAAAAAAAAAAAAAA25ldwAAAAATAAAAAAAAAAI=", + "AAAABQAAAAAAAAAAAAAAEENhdE1hcmtldENyZWF0ZWQAAAABAAAAEmNhdF9tYXJrZXRfY3JlYXRlZAAAAAAABgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAAB2NyZWF0b3IAAAAAEwAAAAAAAAAAAAAACGNhdGVnb3J5AAAH0AAAAAhDYXRlZ29yeQAAAAAAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAAAAAAAtzZWVkX2Ftb3VudAAAAAALAAAAAAAAAEFSZXF1ZXN0ZWQgb3BlbmluZyBwcm9iYWJpbGl0aWVzLCBvbmUgcGVyIG91dGNvbWUsIHN1bW1pbmcgdG8gQlBTLgAAAAAAAAlwcm9ic19icHMAAAAAAAPqAAAABAAAAAAAAAAC", + "AAAABQAAAAAAAAAAAAAAEUNhdE1hcmtldFJlc29sdmVkAAAAAAAAAQAAABNjYXRfbWFya2V0X3Jlc29sdmVkAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAIU5vbmUgbWVhbnMgdGhlIG1hcmtldCB3YXMgdm9pZGVkLgAAAAAAAA1vdXRjb21lX2luZGV4AAAAAAAD6AAAAAQAAAAAAAAAAg==", "AAAABQAAAIxQb3NpdGlvbnMgbW92ZWQgYmV0d2VlbiBob2xkZXJzLiBJbmRleGVycyB0aGF0IHRyYWNrIGJhbGFuY2VzIGZyb20KYFRyYWRlYC9gUmVkZWVtZWRgIG11c3QgYWxzbyBhcHBseSB0aGlzIGV2ZW50IChzZWUgZG9jcy9hcmNoaXRlY3R1cmUubWQpLgAAAAAAAAATUG9zaXRpb25UcmFuc2ZlcnJlZAAAAAABAAAAFHBvc2l0aW9uX3RyYW5zZmVycmVkAAAABQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAABGZyb20AAAATAAAAAQAAAAAAAAACdG8AAAAAABMAAAAAAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAAAAAAAAg==", + "AAAABQAAAAAAAAAAAAAAFE1hcmtldFJlc29sdmVkU2NhbGFyAAAAAQAAABZtYXJrZXRfcmVzb2x2ZWRfc2NhbGFyAAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAH9MT05HIHBheW91dCBwZXIgY29tcGxldGUgc2V0LCAwLi49QlBTOyB0aGUgU0hPUlQgbGVnIHBheXMgdGhlCmNvbXBsZW1lbnQuIGBNYXJrZXRSZXNvbHZlZGAgaXMgbmV2ZXIgZW1pdHRlZCBmb3Igc2NhbGFyIG1hcmtldHMuAAAAAA9wYXlvdXRfbG9uZ19icHMAAAAABAAAAAAAAAAC", "AAAABQAAAAAAAAAAAAAAFFBvb2xSZW1haW5kZXJDbGFpbWVkAAAAAQAAABZwb29sX3JlbWFpbmRlcl9jbGFpbWVkAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAQAAADlQb29sIHJlbWFpbmRlciBwbHVzIGFueSBMUCBmZWVzIGRyYWluZWQgaW4gdGhlIHNhbWUgY2FsbC4AAAAAAAAGcGF5b3V0AAAAAAALAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAAF1Byb3RvY29sRmVlU2hhcmVDaGFuZ2VkAAAAAAEAAAAacHJvdG9jb2xfZmVlX3NoYXJlX2NoYW5nZWQAAAAAAAIAAAAAAAAAA29sZAAAAAAEAAAAAAAAAAAAAAADbmV3AAAAAAQAAAAAAAAAAg==", "AAAAAAAAAPdTcGVuZCBgY29sbGF0ZXJhbF9pbmAgdG8gYnV5IGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMgc2hhcmVzIHJlY2VpdmVkLgpSZXZlcnRzIGlmIGZld2VyIHRoYW4gYG1pbl9zaGFyZXNfb3V0YCB3b3VsZCBiZSByZWNlaXZlZC4KCkxlZ2FjeSBlbnRyeXBvaW50OiBubyBkZWFkbGluZSBhbmQgbm8gbGltaXQgcHJpY2UsIHNvIGEgc2lnbmVkIGJvdW5kCmNhbiBleGVjdXRlIGF0IGFueSBsYXRlciB0aW1lLiBQcmVmZXIgYGJ1eV92MmAuAAAAAANidXkAAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAAAAAA5taW5fc2hhcmVzX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAVZSZWNlaXZlIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lYCBzaGFyZXMuIFJldHVybnMKc2hhcmVzIHNvbGQuIFJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuCkNsaWVudHMgdHVybiAic2VsbCBOIHNoYXJlcyIgaW50byBgY29sbGF0ZXJhbF9vdXRgIG9mZi1jaGFpbgooc2VlIGBtYXRoOjpjYWxjX3NlbGxgIGRvY3MpIGFuZCBhZGQgYSBzbGlwcGFnZSBtYXJnaW4gdG8gYG1heF9zaGFyZXNfaW5gLgoKTGVnYWN5IGVudHJ5cG9pbnQ6IG5vIGRlYWRsaW5lIGFuZCBubyBsaW1pdCBwcmljZS4gUHJlZmVyIGBzZWxsX3YyYC4AAAAAAARzZWxsAAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", "AAAAAAAAAdRgYnV5YCBwbHVzIGEgZGVhZGxpbmUgKGBlbnYubGVkZ2VyKCkudGltZXN0YW1wKClgIHNlY29uZHMg4oCUIG5ldHdvcmsKdGltZSwgbm90IHRoZSBjbGllbnQgY2xvY2spIGFuZCBhbiBhdmVyYWdlLXByaWNlIGxpbWl0CihgbGltaXRfcHJpY2VfYnBzYCBwZXIgc2hhcmUsIDEuMCA9IEJQUzsgMCA9IG5vIGxpbWl0KS4KClRoZSBhdmVyYWdlIHByaWNlIGluY2x1ZGVzIHRoZSBmZWUgKGBmZWVgIGlzIHBhcnQgb2YgYGNvbGxhdGVyYWxfaW5gKQphbmQsIGZvciBhIE5PIGJ1eSwgaXMgY29tcGFyZWQgYWdhaW5zdCB0aGUgTk8gcHJpY2UuIEJlY2F1c2UgdGhlCmRlYWRsaW5lIGNoZWNrIHJ1bnMgYmVmb3JlIGByZXF1aXJlX2F1dGhgLCBhbiBleHBpcmVkIHRyYW5zYWN0aW9uCmZhaWxzIGNoZWFwbHkuIEEgbGltaXQgYWJvdmUgQlBTIGNhbiBuZXZlciBwYXNzIGZvciBhIGJ1eSBhbmQgaXMKcmVqZWN0ZWQgYXMgaW52YWxpZC4AAAAGYnV5X3YyAAAAAAAHAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAAAAAAADm1pbl9zaGFyZXNfb3V0AAAAAAALAAAAAAAAAA9saW1pdF9wcmljZV9icHMAAAAABAAAAAAAAAAIZGVhZGxpbmUAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAGlQYXlzIG91dCBgaG9sZGVyYCdzIHBvc2l0aW9uIGZvciBhIGZpbmFsIG1hcmtldCBhbmQgY2xlYXJzIGl0LgpMb3NpbmcgcG9zaXRpb25zIGNsZWFyIHdpdGggYSBwYXlvdXQgb2YgMC4AAAAAAAAGcmVkZWVtAAAAAAACAAAAAAAAAAZob2xkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAAQhDYXRlZ29yaWNhbC1tYXJrZXQgY291bnRlcnBhcnQgb2YgYGJ1eWA6IHNwZW5kIGBjb2xsYXRlcmFsX2luYCBvbgpgb3V0Y29tZV9pbmRleGAgc2hhcmVzLiBSZXR1cm5zIHNoYXJlcyByZWNlaXZlZDsgcmV2ZXJ0cyBpZiBmZXdlciB0aGFuCmBtaW5fc2hhcmVzX291dGAgd291bGQgYmUgcmVjZWl2ZWQuIE5vIHYyIChkZWFkbGluZS9saW1pdCkgdmFyaWFudAp5ZXQg4oCUIHNlZSB0aGUgY2F0ZWdvcmljYWwgbm90ZXMgaW4gYGRvY3MvYXJjaGl0ZWN0dXJlLm1kYC4AAAAHYnV5X2NhdAAAAAAFAAAAAAAAAAZ0cmFkZXIAAAAAABMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAADW91dGNvbWVfaW5kZXgAAAAAAAAEAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAAAAAAObWluX3NoYXJlc19vdXQAAAAAAAsAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAAAAAAAHcmVzb2x2ZQAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAWxgc2VsbGAgcGx1cyBhIGRlYWRsaW5lIChsZWRnZXIgdGltZXN0YW1wLCBzZWNvbmRzKSBhbmQgYSBtaW5pbXVtCmF2ZXJhZ2UgZXhlY3V0aW9uIHByaWNlIChgbGltaXRfcHJpY2VfYnBzYCBwZXIgc2hhcmUsIDEuMCA9IEJQUykuCgpBIHplcm8gbGltaXQgaXMgdHJpdmlhbGx5IHNhdGlzZmllZCBmb3Igc2VsbHMg4oCUIHVubGlrZSBidXlzLCB3aGVyZSAwCm1lYW5zICJubyBsaW1pdCIg4oCUIGFuZCBhIGxpbWl0IGFib3ZlIEJQUyBjYW4gbmV2ZXIgcGFzcywgc28gbmVpdGhlcgpuZWVkcyBhIHNwZWNpYWwgY2FzZS4gVGhlIGF2ZXJhZ2UgaW5jbHVkZXMgdGhlIGZlZSwgd2hpY2ggdGhlIHRyYWRlcgpwYXlzIG91dCBvZiB0aGUgZ3Jvc3MuAAAAB3NlbGxfdjIAAAAABwAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAAAAAA1tYXhfc2hhcmVzX2luAAAAAAAACwAAAAAAAAAPbGltaXRfcHJpY2VfYnBzAAAAAAQAAAAAAAAACGRlYWRsaW5lAAAABgAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAALhDYXRlZ29yaWNhbC1tYXJrZXQgY291bnRlcnBhcnQgb2YgYHNlbGxgOiByZWNlaXZlIGV4YWN0bHkKYGNvbGxhdGVyYWxfb3V0YCBieSBzZWxsaW5nIGBvdXRjb21lX2luZGV4YCBzaGFyZXMuIFJldHVybnMgc2hhcmVzCnNvbGQ7IHJldmVydHMgaWYgbW9yZSB0aGFuIGBtYXhfc2hhcmVzX2luYCB3b3VsZCBiZSBuZWVkZWQuAAAACHNlbGxfY2F0AAAABQAAAAAAAAAGdHJhZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAA1vdXRjb21lX2luZGV4AAAAAAAABAAAAAAAAAAOY29sbGF0ZXJhbF9vdXQAAAAAAAsAAAAAAAAADW1heF9zaGFyZXNfaW4AAAAAAAALAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAD9TcG90IHByaWNlIG9mIGBvdXRjb21lYCBpbiBicHMgKDAuLj0xMF8wMDApLiBZRVMgKyBOTyB+IDEwXzAwMC4AAAAACWdldF9wcmljZQAAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAABAAAAAM=", - "AAAAAAAAADlTaGFyZXMgYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgcmV0dXJuIHJpZ2h0IG5vdy4AAAAAAAAJcXVvdGVfYnV5AAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA1jb2xsYXRlcmFsX2luAAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAH5TaGFyZXMgYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgcmV0dXJuIHJpZ2h0IG5vdywgYXQgdGhlCmVmZmVjdGl2ZSAocG9zc2libHkgZHluYW1pYykgZmVlIGZvciB0aGlzIGxlZGdlcidzIHRpbWVzdGFtcC4AAAAAAAlxdW90ZV9idXkAAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAAKZ2V0X2NvbmZpZwAAAAAAAAAAAAEAAAPpAAAH0AAAAAZDb25maWcAAAAAAAM=", "AAAAAAAAAAAAAAAKZ2V0X21hcmtldAAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAH0AAAAAZNYXJrZXQAAAAAAAM=", - "AAAAAAAAAEVTaGFyZXMgYSBgc2VsbGAgZm9yIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCB3b3VsZCBjb25zdW1lIHJpZ2h0IG5vdy4AAAAAAAAKcXVvdGVfc2VsbAAAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA5jb2xsYXRlcmFsX291dAAAAAAACwAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAIpTaGFyZXMgYSBgc2VsbGAgZm9yIGV4YWN0bHkgYGNvbGxhdGVyYWxfb3V0YCB3b3VsZCBjb25zdW1lIHJpZ2h0IG5vdywKYXQgdGhlIGVmZmVjdGl2ZSAocG9zc2libHkgZHluYW1pYykgZmVlIGZvciB0aGlzIGxlZGdlcidzIHRpbWVzdGFtcC4AAAAAAApxdW90ZV9zZWxsAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADmNvbGxhdGVyYWxfb3V0AAAAAAALAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAAL5DYXRlZ29yaWNhbC1tYXJrZXQgY291bnRlcnBhcnQgb2YgYHJlZGVlbWAuIFdpbm5pbmcgYmFsYW5jZXMgcGF5IDE6MSwKYSB2b2lkIHBheXMgYGZsb29yKM6jIGJhbGFuY2VzIC8gbilgIOKAlCBib3RoIGZsb29yZWQsIHNvIHRoZSB0b3RhbCBwYWlkCmFjcm9zcyBhbGwgaG9sZGVycyBuZXZlciBleGNlZWRzIGBzZXRzX21pbnRlZGAuAAAAAAAKcmVkZWVtX2NhdAAAAAAAAgAAAAAAAAAGaG9sZGVyAAAAAAATAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAAKc2V0X3BhdXNlZAAAAAAAAQAAAAAAAAAGcGF1c2VkAAAAAAABAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAAAAAQpBbnlvbmUgY2FuIG1vdmUgYWNjcnVlZCBwcm90b2NvbCBmZWVzIHRvIHRoZSBtYXJrZXQncyBmZWUgcmVjaXBpZW50LgoKU2VuZHMgdG8gdGhlIHJlY2lwaWVudCBzbmFwc2hvdHRlZCBpbiBgRmVlVGVybXNgIGF0IGNyZWF0aW9uLiBNYXJrZXRzCmNyZWF0ZWQgYmVmb3JlIGBGZWVUZXJtc2AgZXhpc3RlZCBoYXZlIG5vbmUgYW5kIGtlZXAgdGhlIGxlZ2FjeQpiZWhhdmlvdXI6IHRoZSBgRGF0YUtleTo6VHJlYXN1cnlgIG9mIHRoZSBtb21lbnQgb2YgdGhlIHN3ZWVwLgAAAAAACnN3ZWVwX2ZlZXMAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAsAAAAD", + "AAAAAAAAASdBbnlvbmUgY2FuIG1vdmUgYWNjcnVlZCBwcm90b2NvbCBmZWVzIHRvIHRoZSBtYXJrZXQncyBmZWUgcmVjaXBpZW50LgoKU2VuZHMgdG8gdGhlIHJlY2lwaWVudCBzbmFwc2hvdHRlZCBpbiBgRmVlVGVybXNgIGF0IGNyZWF0aW9uLiBNYXJrZXRzCmNyZWF0ZWQgYmVmb3JlIGBGZWVUZXJtc2AgZXhpc3RlZCBoYXZlIG5vbmUgYW5kIGtlZXAgdGhlIGxlZ2FjeQpiZWhhdmlvdXI6IHRoZSBgRGF0YUtleTo6VHJlYXN1cnlgIG9mIHRoZSBtb21lbnQgb2YgdGhlIHN3ZWVwLgpXb3JrcyBmb3IgYm90aCBtYXJrZXQga2luZHMuAAAAAApzd2VlcF9mZWVzAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAPVUcnVlIHdoZW4gYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgYmUgcmVqZWN0ZWQgYnkgdGhlCm1hcmtldCdzIGJyZWFrZXJzIHJpZ2h0IG5vdy4gUmVhZC1vbmx5OiBuZXZlciB3cml0ZXMgYmFuZCBzdGF0ZSwgc28Kb24gYSBmcmVzaCBsZWRnZXIgdGhlIHJlZmVyZW5jZSBpcyB0YWtlbiBhcyB0aGUgY3VycmVudCBwcmljZSDigJQgdGhlCnNhbWUgdmFsdWUgYSBzdWJzZXF1ZW50IHJlYWwgdHJhZGUgd291bGQgdXNlLgAAAAAAAAp3b3VsZF90cmlwAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAADWNvbGxhdGVyYWxfaW4AAAAAAAALAAAAAQAAA+kAAAABAAAAAw==", + "AAAAAAAAAHFXaGV0aGVyIGEgbWFya2V0IGlkIGlzIGEgYmluYXJ5IChgTWFya2V0YCkgb3IgY2F0ZWdvcmljYWwKKGBDYXRNYXJrZXRgKSBtYXJrZXQuIGBNYXJrZXROb3RGb3VuZGAgZm9yIHVua25vd24gaWRzLgAAAAAAAAttYXJrZXRfa2luZAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAfQAAAACk1hcmtldEtpbmQAAAAAAAM=", + "AAAAAAAAAFlDYXRlZ29yaWNhbC1tYXJrZXQgY291bnRlcnBhcnQgb2YgYHJlc29sdmVgLiBTYW1lIHJlc29sdmVyLW9ubHkgZ2F0ZQphbmQgY2xvc2UtdGltZSBydWxlLgAAAAAAAAtyZXNvbHZlX2NhdAAAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAA1vdXRjb21lX2luZGV4AAAAAAAABAAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAL9UcmFuc2ZlcnMgdGhlIHNpbmdsZSBMUCByb2xlICh0aGUgcmlnaHQgdG8gYGNsYWltX3Bvb2xfcmVtYWluZGVyYCBhbmQKYGNsYWltX2xwX2ZlZXNgKSB0byBgbmV3X2NyZWF0b3JgLiBPbmx5IHVudGlsIHRoZSByZW1haW5kZXIgaGFzIGJlZW4KY2xhaW1lZDsgYWZ0ZXIgdGhhdCB0aGUgcm9sZSBoYXMgbm8gcmVtYWluaW5nIHZhbHVlLgAAAAALdHJhbnNmZXJfbHAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAALbmV3X2NyZWF0b3IAAAAAEwAAAAEAAAPpAAAAAgAAAAM=", - "AAAAAAAAAEFJbnZhbGlkIG9yIHVucmVzb2x2YWJsZSBtYXJrZXQuIEFsbG93ZWQgYW55IHRpbWUgYmVmb3JlIGZpbmFsaXR5LgAAAAAAAAt2b2lkX21hcmtldAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAALtJbnZhbGlkIG9yIHVucmVzb2x2YWJsZSBtYXJrZXQuIEFsbG93ZWQgYW55IHRpbWUgYmVmb3JlIGZpbmFsaXR5LgpEaXNwYXRjaGVzIG9uIHRoZSBtYXJrZXQga2luZDogYmluYXJ5IG1hcmtldHMgZW1pdCBgTWFya2V0UmVzb2x2ZWRgCndpdGggYE5vbmVgLCBjYXRlZ29yaWNhbCBtYXJrZXRzIGBDYXRNYXJrZXRSZXNvbHZlZGAuAAAAAAt2b2lkX21hcmtldAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAINUaGUgbWFya2V0J3Mgc25hcHNob3R0ZWQgZHluYW1pYyBmZWUgc2NoZWR1bGUgKGBOb25lYCBmb3IgbGVnYWN5IGFuZApjYXRlZ29yaWNhbCBtYXJrZXRzLCB3aGljaCBjaGFyZ2UgdGhlIGZsYXQgYE1hcmtldDo6ZmVlX2Jwc2ApLgAAAAAMZmVlX3NjaGVkdWxlAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAD6AAAB9AAAAALRmVlU2NoZWR1bGUAAAAAAw==", "AAAAAAAAAAAAAAAMZ2V0X3Bvc2l0aW9uAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAGaG9sZGVyAAAAAAATAAAAAQAAB9AAAAAIUG9zaXRpb24=", "AAAAAAAAAAAAAAAMbWFya2V0X2NvdW50AAAAAAAAAAEAAAAG", + "AAAAAAAAAS9TaGFyZXMgYSBgYnV5YCBvZiBgY29sbGF0ZXJhbF9pbmAgd291bGQgcmV0dXJuIGF0IGxlZGdlciB0aW1lc3RhbXAKYGF0X3RpbWVzdGFtcGAuIFVuZGVyIGEgcmFtcGluZyBzY2hlZHVsZSB0aGUgZmVlIHJpc2VzIHdpdGggdGltZSwgc28KY2xpZW50cyBleHBlY3RpbmcgaW5jbHVzaW9uIGxhdGVuY3kgY2FuIHF1b3RlIHRoZSB3b3JzdCBjYXNlIGhlcmUKKHNlZSBgZG9jcy9mcm9udGVuZC1pbnRlZ3JhdGlvbi5tZGApLiBObyBvdGhlciBzdGF0ZSBpcyBzaW11bGF0ZWQ6CnRoZSByZXNlcnZlcyBhcmUgdGhlIGN1cnJlbnQgb25lcy4AAAAADHF1b3RlX2J1eV9hdAAAAAQAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAAAAAAADGF0X3RpbWVzdGFtcAAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAAAAAAAMc2V0X3Jlc29sdmVyAAAAAQAAAAAAAAAIcmVzb2x2ZXIAAAATAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAJtBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbCBhbmQgdGhlIGxlZ2FjeSBmYWxsYmFjayBpbgpgc3dlZXBfZmVlc2AuIE1hcmtldHMgd2l0aCBhIHNuYXBzaG90dGVkIGBGZWVUZXJtc2Aga2VlcCBwYXlpbmcgdGhlaXIKb3JpZ2luYWwgcmVjaXBpZW50LgAAAAAMc2V0X3RyZWFzdXJ5AAAAAQAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAQhSdW5zIGF0b21pY2FsbHkgYXQgZGVwbG95IHRpbWUsIHNvIG5vYm9keSBjYW4gZnJvbnQtcnVuIGluaXRpYWxpemF0aW9uLgpgcHJvdG9jb2xfZmVlX3NoYXJlX2Jwc2AgaXMgdGhlIGRlZmF1bHQgcHJvdG9jb2wvTFAgc3BsaXQgZm9yIG5ldwptYXJrZXRzOyAxMF8wMDAga2VlcHMgdGhlIHdob2xlIGZlZSB3aXRoIHRoZSBwcm90b2NvbCAocHJlLXVwZ3JhZGUKYmVoYXZpb3VyKS4gUGFzcyAwIHRvIGdpdmUgYWxsIGZlZXMgdG8gdGhlIG1hcmtldCBjcmVhdG9ycy4AAAANX19jb25zdHJ1Y3RvcgAAAAAAAAYAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAKY29sbGF0ZXJhbAAAAAAAEwAAAAAAAAAIdHJlYXN1cnkAAAATAAAAAAAAAAhyZXNvbHZlcgAAABMAAAAAAAAAD2RlZmF1bHRfZmVlX2JwcwAAAAAEAAAAAAAAABZwcm90b2NvbF9mZWVfc2hhcmVfYnBzAAAAAAAEAAAAAA==", "AAAAAAAAALpUaGUgY3VycmVudCBsZWRnZXIncyBiYW5kIHJlZmVyZW5jZSwgaWYgYSBiYW5kIGNoZWNrIGhhcyBydW4gdGhpcwpsZWRnZXIuIGBOb25lYCBtZWFucyB0aGUgbmV4dCB0cmFkZSBzZXRzIHRoZSByZWZlcmVuY2UgKHRvIGl0cyBvd24KcHJlLXRyYWRlIHByaWNlLCB3aGljaCBlcXVhbHMgdGhlIGxlZGdlci1vcGVuIHByaWNlKS4AAAAAAA1icmVha2VyX3N0YXRlAAAAAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAD6AAAB9AAAAAJQmFuZFN0YXRlAAAAAAAAAw==", - "AAAAAAAAALVUaGUgY3JlYXRvciAodGhlIExQKSBjbGFpbXMgdGhlaXIgc2hhcmUgb2YgYWNjcnVlZCB0cmFkZSBmZWVzIGF0IGFueQp0aW1lLCBpbmNsdWRpbmcgYmVmb3JlIGZpbmFsaXR5LiBSZXR1cm5zIHRoZSBhbW91bnQgY2xhaW1lZCAoMCB3aGVuCm5vdGhpbmcgaXMgcGVuZGluZywgbWlycm9yaW5nIGBzd2VlcF9mZWVzYCkuAAAAAAAADWNsYWltX2xwX2ZlZXMAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", - "AAAAAAAAAMhDcmVhdGVzIGEgbWFya2V0IGFuZCBzZWVkcyB0aGUgcG9vbC4gYGNyZWF0b3JgIGJlY29tZXMgdGhlIHNvbGUgTFAuCmBpbml0aWFsX3llc19icHNgIGluIFsxMDAsIDk5MDBdIHNldHMgdGhlIG9wZW5pbmcgWUVTIHByb2JhYmlsaXR5Owp0aGUgY3JlYXRvciByZWNlaXZlcyB0aGUgbGVmdG92ZXIgc2hhcmVzIG9mIHRoZSBsaWtlbGllciBvdXRjb21lLgAAAA1jcmVhdGVfbWFya2V0AAAAAAAACAAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAAQtUaGUgY3JlYXRvciAodGhlIExQKSBjbGFpbXMgdGhlaXIgc2hhcmUgb2YgYWNjcnVlZCB0cmFkZSBmZWVzIGF0IGFueQp0aW1lLCBpbmNsdWRpbmcgYmVmb3JlIGZpbmFsaXR5LiBSZXR1cm5zIHRoZSBhbW91bnQgY2xhaW1lZCAoMCB3aGVuCm5vdGhpbmcgaXMgcGVuZGluZywgbWlycm9yaW5nIGBzd2VlcF9mZWVzYCkuIFdvcmtzIGZvciBib3RoIG1hcmtldApraW5kcyAodGhlIGZlZSBzaWRlLWVudHJpZXMgYXJlIGtleWVkIGJ5IHRoZSBzaGFyZWQgbWFya2V0IGlkKS4AAAAADWNsYWltX2xwX2ZlZXMAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAAW5DcmVhdGVzIGEgbWFya2V0IGFuZCBzZWVkcyB0aGUgcG9vbC4gYGNyZWF0b3JgIGJlY29tZXMgdGhlIHNvbGUgTFAuCmBpbml0aWFsX3llc19icHNgIGluIFsxMDAsIDk5MDBdIHNldHMgdGhlIG9wZW5pbmcgWUVTIHByb2JhYmlsaXR5Owp0aGUgY3JlYXRvciByZWNlaXZlcyB0aGUgbGVmdG92ZXIgc2hhcmVzIG9mIHRoZSBsaWtlbGllciBvdXRjb21lLgpXaGVuIGEgZGVmYXVsdCBkeW5hbWljIGZlZSBzY2hlZHVsZSBpcyBzZXQgaXQgaXMgc25hcHNob3R0ZWQgZm9yCnRoaXMgbWFya2V0IChzZWUgYHNldF9kZWZhdWx0X2ZlZV9zY2hlZHVsZWApOyBvdGhlcndpc2UgdGhlIG1hcmtldApjaGFyZ2VzIHRoZSBmbGF0IGBEZWZhdWx0RmVlQnBzYC4AAAAAAA1jcmVhdGVfbWFya2V0AAAAAAAACAAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAAD1TaGFyZXMgYSBgYnV5X2NhdGAgb2YgYGNvbGxhdGVyYWxfaW5gIHdvdWxkIHJldHVybiByaWdodCBub3cuAAAAAAAADXF1b3RlX2J1eV9jYXQAAAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAA1vdXRjb21lX2luZGV4AAAAAAAABAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAAHpUaGUgbWFya2V0J3Mgc25hcHNob3R0ZWQgYnJlYWtlcnMgKGBOb25lYCBmb3IgbGVnYWN5IG1hcmtldHMgY3JlYXRlZApiZWZvcmUgYnJlYWtlcnMgZXhpc3RlZCDigJQgdGhvc2UgYXJlIG5ldmVyIGNoZWNrZWQpLgAAAAAADmJyZWFrZXJfY29uZmlnAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAPoAAAH0AAAAA1CcmVha2VyQ29uZmlnAAAAAAAAAw==", + "AAAAAAAAAAAAAAAOZ2V0X2NhdF9tYXJrZXQAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAJQ2F0TWFya2V0AAAAAAAAAw==", + "AAAAAAAAAPNTcG90IHByaWNlcyBvZiBhbGwgb3V0Y29tZXMgaW4gYnBzLCBzYW1lIG9yZGVyIGFzIHRoZSByZXNlcnZlcy4gRWFjaApwcmljZSBpcyBmbG9vcmVkLCBzbyB0aGV5IHN1bSB0byBhdCBtb3N0IEJQUyBhbmQgYXQgbGVhc3QgQlBTIC0gbjsKY2xpZW50cyBzaG91bGQgdHJlYXQgdGhlIHNsYWNrIGFzIGRpc3BsYXkgcm91bmRpbmcsIG5ldmVyIGFzIGEKbWlzcHJpY2luZyAocHJpY2VzIGRvIG5vdCBlbnRlciB0cmFkZSBtYXRoKS4AAAAADmdldF9wcmljZXNfY2F0AAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAPqAAAABAAAAAM=", + "AAAAAAAAAMhTY2FsYXItbWFya2V0IGNvdW50ZXJwYXJ0IG9mIGByZXNvbHZlYC4gYHBheW91dF9sb25nX2Jwc2AgaXMgdGhlCmZpbmFsIExPTkcgcGF5b3V0IGluIGJhc2lzIHBvaW50cyBvZiAxLjAgKDAuLj0xMF8wMDApOyB0aGUgU0hPUlQgc2lkZQpwYXlzIHRoZSBjb21wbGVtZW50LiBTYW1lIHJlc29sdmVyLW9ubHkgZ2F0ZSBhbmQgY2xvc2UtdGltZSBydWxlLgAAAA5yZXNvbHZlX3NjYWxhcgAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAPcGF5b3V0X2xvbmdfYnBzAAAAAAQAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAStUaGUgZmVlIGJwcyB0aGlzIGxlZGdlcidzIG5leHQgdHJhZGUgb24gYG1hcmtldF9pZGAgcGF5czogdGhlCmR5bmFtaWMgZWZmZWN0aXZlIGZlZSBmb3IgYmluYXJ5IG1hcmtldHMgd2l0aCBhIHNjaGVkdWxlLCB0aGUgZmxhdApzbmFwc2hvdCBmb3IgbGVnYWN5IGFuZCBjYXRlZ29yaWNhbCBtYXJrZXRzLiBVbmRlciBhIHJhbXBpbmcKc2NoZWR1bGUgdGhlIHZhbHVlIGNoYW5nZXMgd2l0aCB0aGUgbGVkZ2VyIHRpbWVzdGFtcDsgdXNlCmBxdW90ZV9idXlfYXRgIHRvIHByaWNlIGV4cGVjdGVkIGluY2x1c2lvbiBsYXRlbmN5LgAAAAAPY3VycmVudF9mZWVfYnBzAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAQAAAAD", + "AAAAAAAAAGJEaXNwbGF5IG1ldGFkYXRhICh0aGUgc3Rha2VkIHJhbmdlKSBvZiBhIHNjYWxhciBtYXJrZXQuIEFic2VudCBmb3IKYmluYXJ5IGFuZCBjYXRlZ29yaWNhbCBtYXJrZXRzLgAAAAAAD2dldF9zY2FsYXJfbWV0YQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAfQAAAAClNjYWxhck1ldGEAAAAAAAM=", + "AAAAAAAAAIdBIGhvbGRlcidzIGNhdGVnb3JpY2FsIHBvc2l0aW9uLCB6ZXJvLWZpbGxlZCB0byB0aGUgbWFya2V0J3Mgb3V0Y29tZQpjb3VudCB3aGVuIHRoZXkgbmV2ZXIgdHJhZGVkLiBFcnJvcnMgaWYgdGhlIG1hcmtldCBkb2VzIG5vdCBleGlzdC4AAAAAEGdldF9jYXRfcG9zaXRpb24AAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZob2xkZXIAAAAAABMAAAABAAAD6QAAB9AAAAALQ2F0UG9zaXRpb24AAAAAAw==", "AAAAAAAAADdNb3ZlcyBgYW1vdW50YCBzaGFyZXMgb2YgYG91dGNvbWVgIGZyb20gYGZyb21gIHRvIGB0b2AuAAAAABF0cmFuc2Zlcl9wb3NpdGlvbgAAAAAAAAUAAAAAAAAABGZyb20AAAATAAAAAAAAAAJ0bwAAAAAAEwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAAZhbW91bnQAAAAAAAsAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAOZTZXRzIHRoZSBicmVha2VyIGRlZmF1bHRzIGNvcGllZCBpbnRvIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbC4KYGJhbmRfYnBzIDwgbWF4X2ltcGFjdF9icHNgIChib3RoIG5vbi16ZXJvKSBpcyByZWplY3RlZDogYSBzaW5nbGUKdHJhZGUgdGhhdCBpcyBhbGxvd2VkIGJ5IHRoZSBpbXBhY3QgY2FwIGNvdWxkIHRoZW4gbmV2ZXIgZml0IGluc2lkZQp0aGUgYmFuZCwgbG9ja2luZyB0cmFkaW5nLgAAAAAAE3NldF9kZWZhdWx0X2JyZWFrZXIAAAAAAQAAAAAAAAAHYnJlYWtlcgAAAAfQAAAADUJyZWFrZXJDb25maWcAAAAAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAEhBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZWlyIGZlZS4AAAATc2V0X2RlZmF1bHRfZmVlX2JwcwAAAAABAAAAAAAAAAdmZWVfYnBzAAAAAAQAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAHxDcmVhdG9yICh0aGUgTFApIHdpdGhkcmF3cyB3aGF0ZXZlciB0aGUgcG9vbCBzdGlsbCBob2xkcyBvbmNlIGZpbmFsLApwbHVzIGFueSB1bmNsYWltZWQgTFAgZmVlcy4gUmV0dXJucyB0aGUgdG90YWwgcGFpZCBvdXQuAAAAFGNsYWltX3Bvb2xfcmVtYWluZGVyAAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAACwAAAAM=", + "AAAAAAAAAX5DcmVhdGVzIGEgc2NhbGFyIChyYW5nZSkgbWFya2V0OiB0aGUgYmluYXJ5IHBvb2wgd2l0aCBMT05HID0gWUVTIGFuZApTSE9SVCA9IE5PLCB3aG9zZSBmaW5hbCBzdGF0dXMgY2FycmllcyBhIGZyYWN0aW9uYWwgcGF5b3V0CihgTWFya2V0U3RhdHVzOjpSZXNvbHZlZFNjYWxhcmApIGluc3RlYWQgb2YgYSBzaWRlLiBFdmVyeXRoaW5nIGVsc2UKbWF0Y2hlcyBgY3JlYXRlX21hcmtldGA7IGBbbG93ZXIsIHVwcGVyXWAgaXMgZGlzcGxheSBtZXRhZGF0YSBzdG9yZWQKdW5kZXIgYERhdGFLZXk6OlNjYWxhcmAgKHRoZSBhdXRob3JpdGF0aXZlIHJhbmdlIGlzIGluc2lkZSB0aGUKYFJlc29sdXRpb25TcGVjYCBjb21taXR0ZWQgdGhyb3VnaCBgcmVzb2x1dGlvbl9oYXNoYCkuAAAAAAAUY3JlYXRlX3NjYWxhcl9tYXJrZXQAAAAKAAAAAAAAAAdjcmVhdG9yAAAAABMAAAAAAAAADXF1ZXN0aW9uX2hhc2gAAAAAAAPuAAAAIAAAAAAAAAAPcmVzb2x1dGlvbl9oYXNoAAAAA+4AAAAgAAAAAAAAAAhtZXRhX3VyaQAAABAAAAAAAAAACGNhdGVnb3J5AAAH0AAAAAhDYXRlZ29yeQAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAALc2VlZF9hbW91bnQAAAAACwAAAAAAAAAPaW5pdGlhbF95ZXNfYnBzAAAAAAQAAAAAAAAABWxvd2VyAAAAAAAACwAAAAAAAAAFdXBwZXIAAAAAAAALAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAAO5gY3JlYXRlX21hcmtldGAgd2l0aCBhbiBleHBsaWNpdCBwZXItbWFya2V0IGBGZWVTY2hlZHVsZWAuIFZhbGlkYXRlZAphZ2FpbnN0IGBNQVhfRkVFX0JQU2AgYW5kIHRoZSBjdXJyZW50IGRlZmF1bHQgc2NoZWR1bGUncyBgbWF4X2Jwc2AKKGNyZWF0b3JzIGNhbiB1bmRlcmN1dCBwcm90b2NvbCBwb2xpY3ksIG5ldmVyIGV4Y2VlZCBpdCksIHRoZW4Kc25hcHNob3R0ZWQgZm9yIHRoaXMgbWFya2V0J3MgbGlmZXRpbWUuAAAAAAAXY3JlYXRlX21hcmtldF93aXRoX2ZlZXMAAAAACQAAAAAAAAAHY3JlYXRvcgAAAAATAAAAAAAAAA1xdWVzdGlvbl9oYXNoAAAAAAAD7gAAACAAAAAAAAAAD3Jlc29sdXRpb25faGFzaAAAAAPuAAAAIAAAAAAAAAAIbWV0YV91cmkAAAAQAAAAAAAAAAhjYXRlZ29yeQAAB9AAAAAIQ2F0ZWdvcnkAAAAAAAAACmNsb3NlX3RpbWUAAAAAAAYAAAAAAAAAC3NlZWRfYW1vdW50AAAAAAsAAAAAAAAAD2luaXRpYWxfeWVzX2JwcwAAAAAEAAAAAAAAAAhzY2hlZHVsZQAAB9AAAAALRmVlU2NoZWR1bGUAAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAAItDYXRlZ29yaWNhbC1tYXJrZXQgY291bnRlcnBhcnQgb2YgYGNsYWltX3Bvb2xfcmVtYWluZGVyYDogdGhlIGNyZWF0b3IKKHRoZSBMUCkgd2l0aGRyYXdzIHRoZSBzZXR0bGVkIHBvb2wgdmFsdWUgcGx1cyBhbnkgdW5jbGFpbWVkIExQIGZlZXMuAAAAABhjbGFpbV9wb29sX3JlbWFpbmRlcl9jYXQAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAALAAAAAw==", + "AAAAAAAAAUBTZXRzIHRoZSBkeW5hbWljIGZlZSBzY2hlZHVsZSBzbmFwc2hvdHRlZCBpbnRvIGJpbmFyeSBtYXJrZXRzIGNyZWF0ZWQKYWZ0ZXIgdGhlIGNhbGwuIEJvdW5kZWQgYnkgYE1BWF9GRUVfQlBTYCBhbmQgdGhlIHJhbXAvc2tldyBjYXBzOwpleGlzdGluZyBtYXJrZXRzIGtlZXAgdGhlaXIgc2NoZWR1bGUuIEEgY3JlYXRvciBjYW4gcGljayBhIHN0cmljdGVyCnNjaGVkdWxlIHRocm91Z2ggYGNyZWF0ZV9tYXJrZXRfd2l0aF9mZWVzYCwgbmV2ZXIgYSBsb29zZXIgb25lIChpdHMKYG1heF9icHNgIGlzIGNhcHBlZCBieSB0aGlzIGRlZmF1bHQncyBgbWF4X2Jwc2ApLgAAABhzZXRfZGVmYXVsdF9mZWVfc2NoZWR1bGUAAAABAAAAAAAAAAhzY2hlZHVsZQAAB9AAAAALRmVlU2NoZWR1bGUAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAtNDcmVhdGVzIGEgY2F0ZWdvcmljYWwgbWFya2V0IHdpdGggMi4uPTggb3V0Y29tZXMgYW5kIHNlZWRzIHRoZSBwb29sLgpgY3JlYXRvcmAgYmVjb21lcyB0aGUgc29sZSBMUCBhbmQgcmVjZWl2ZXMgdGhlIGxlZnRvdmVyIHNoYXJlcyBvZiB0aGUKbGlrZWxpZXIgb3V0Y29tZXMuIGBwcm9ic19icHNgIG11c3QgaGF2ZSBvbmUgZW50cnkgcGVyIG91dGNvbWUgd2l0aApldmVyeSBwcm9iYWJpbGl0eSBpbiBgbWF0aDo6TUlOX1BST0JfQlBTLi49bWF0aDo6TUFYX1BST0JfQlBTYCBhbmQgdGhlCmVudHJpZXMgc3VtbWluZyB0byBleGFjdGx5IEJQUy4KCkNvbGxhdGVyYWwsIGlkIHNwYWNlLCBwYXVzZSBzd2l0Y2gsIGZlZSBzbmFwc2hvdCBhbmQgYHJlc29sdXRpb25faGFzaGAKY29tbWl0bWVudCBhcmUgc2hhcmVkIHdpdGggYGNyZWF0ZV9tYXJrZXRgOyB0aGUgcG9vbCBpcyB0aGUgc2FtZSBGUE1NCndpdGggdGhlIHJlc2VydmUtcHJvZHVjdCBpbnZhcmlhbnQgZ2VuZXJhbGlzZWQgdG8gbiBvdXRjb21lcyAoc2VlCmBtYXRoX2NhdGVnb3JpY2FsYCkuIFBlci1tYXJrZXQgY2lyY3VpdCBicmVha2VycyBhcmUgcHJpY2UtYmFzZWQgYW5kCmJpbmFyeS1vbmx5LCBzbyBjYXRlZ29yaWNhbCBtYXJrZXRzIGFyZSBub3Qgc25hcHNob3R0ZWQgd2l0aCB0aGVtIOKAlApub3Igd2l0aCBhIGBGZWVTY2hlZHVsZWA6IHRoZXkgYWx3YXlzIGNoYXJnZSB0aGVpciBmbGF0IGBmZWVfYnBzYC4AAAAAGWNyZWF0ZV9jYXRlZ29yaWNhbF9tYXJrZXQAAAAAAAAIAAAAAAAAAAdjcmVhdG9yAAAAABMAAAAAAAAADXF1ZXN0aW9uX2hhc2gAAAAAAAPuAAAAIAAAAAAAAAAPcmVzb2x1dGlvbl9oYXNoAAAAA+4AAAAgAAAAAAAAAAhtZXRhX3VyaQAAABAAAAAAAAAACGNhdGVnb3J5AAAH0AAAAAhDYXRlZ29yeQAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAALc2VlZF9hbW91bnQAAAAACwAAAAAAAAAJcHJvYnNfYnBzAAAAAAAD6gAAAAQAAAABAAAD6QAAAAYAAAAD", "AAAAAAAAAGVBZmZlY3RzIG1hcmtldHMgY3JlYXRlZCBhZnRlciB0aGUgY2FsbDsgZXhpc3RpbmcgbWFya2V0cyBrZWVwIHRoZQpzcGxpdCB0aGV5IHNuYXBzaG90dGVkIGF0IGNyZWF0aW9uLgAAAAAAABpzZXRfcHJvdG9jb2xfZmVlX3NoYXJlX2JwcwAAAAAAAQAAAAAAAAAJc2hhcmVfYnBzAAAAAAAABAAAAAEAAAPpAAAAAgAAAAM=" ]), options ) @@ -500,30 +819,49 @@ export class Client extends ContractClient { sell: this.txFromJSON>, buy_v2: this.txFromJSON>, redeem: this.txFromJSON>, + buy_cat: this.txFromJSON>, resolve: this.txFromJSON>, sell_v2: this.txFromJSON>, + sell_cat: this.txFromJSON>, get_price: this.txFromJSON>, quote_buy: this.txFromJSON>, get_config: this.txFromJSON>, get_market: this.txFromJSON>, quote_sell: this.txFromJSON>, + redeem_cat: this.txFromJSON>, set_paused: this.txFromJSON>, sweep_fees: this.txFromJSON>, would_trip: this.txFromJSON>, + market_kind: this.txFromJSON>, + resolve_cat: this.txFromJSON>, transfer_lp: this.txFromJSON>, void_market: this.txFromJSON>, + fee_schedule: this.txFromJSON>>, get_position: this.txFromJSON, market_count: this.txFromJSON, + quote_buy_at: this.txFromJSON>, set_resolver: this.txFromJSON>, set_treasury: this.txFromJSON>, breaker_state: this.txFromJSON>>, claim_lp_fees: this.txFromJSON>, create_market: this.txFromJSON>, + quote_buy_cat: this.txFromJSON>, breaker_config: this.txFromJSON>>, + get_cat_market: this.txFromJSON>, + get_prices_cat: this.txFromJSON>>, + resolve_scalar: this.txFromJSON>, + current_fee_bps: this.txFromJSON>, + get_scalar_meta: this.txFromJSON>, + get_cat_position: this.txFromJSON>, transfer_position: this.txFromJSON>, set_default_breaker: this.txFromJSON>, set_default_fee_bps: this.txFromJSON>, claim_pool_remainder: this.txFromJSON>, + create_scalar_market: this.txFromJSON>, + create_market_with_fees: this.txFromJSON>, + claim_pool_remainder_cat: this.txFromJSON>, + set_default_fee_schedule: this.txFromJSON>, + create_categorical_market: this.txFromJSON>, set_protocol_fee_share_bps: this.txFromJSON> } } \ No newline at end of file diff --git a/oracledesk-stellar/packages/bindings/resolver/src/index.ts b/oracledesk-stellar/packages/bindings/resolver/src/index.ts index 5251a4c..a585b9d 100644 --- a/oracledesk-stellar/packages/bindings/resolver/src/index.ts +++ b/oracledesk-stellar/packages/bindings/resolver/src/index.ts @@ -58,7 +58,11 @@ export const Errors = { 22: {message:"StaleRound"}, 23: {message:"TooManySignatures"}, 24: {message:"DisputeWindowClosed"}, - 25: {message:"NoDispute"} + 25: {message:"NoDispute"}, + /** + * `attest_cat` outcome index not below the committed `outcome_count`. + */ + 26: {message:"InvalidOutcomeIndex"} } /** @@ -75,7 +79,7 @@ export type Escalation = {tag: "Guardian", values: void} | {tag: "Committee", va /** * Which resolution path a market was on when it expired. */ -export type ExpiryMode = {tag: "Unrevealed", values: void} | {tag: "Price", values: void} | {tag: "Signers", values: void}; +export type ExpiryMode = {tag: "Unrevealed", values: void} | {tag: "Price", values: void} | {tag: "Signers", values: void} | {tag: "Scalar", values: void}; export type OracleAsset = {tag: "Stellar", values: readonly [string]} | {tag: "Other", values: readonly [string]}; @@ -113,10 +117,28 @@ export type PriceDirection = {tag: "Above", values: void} | {tag: "AtOrAbove", v * this (rather than the bare `PriceConfig`/`SignerConfig`) keeps the two * modes in disjoint hash spaces even if their field encodings could * otherwise collide. See `docs/adr/0001-resolution-commitment.md`. + * + * Variants are append-only: the XDR tag is the variant position, so a new + * mode goes at the end and existing commitments never move. */ -export type ResolutionSpec = {tag: "Price", values: readonly [PriceConfig]} | {tag: "Signers", values: readonly [SignerConfig]} | {tag: "WeightedKeys", values: readonly [WeightedKeyConfig]} | {tag: "SignersDisputable", values: readonly [DisputableSignerConfig]}; +export type ResolutionSpec = {tag: "Price", values: readonly [PriceConfig]} | {tag: "Signers", values: readonly [SignerConfig]} | {tag: "WeightedKeys", values: readonly [WeightedKeyConfig]} | {tag: "SignersDisputable", values: readonly [DisputableSignerConfig]} | {tag: "CategoricalSigners", values: readonly [CatSignerConfig]} | {tag: "Scalar", values: readonly [ScalarPriceConfig]}; + +/** + * Categorical signer committee: like [`SignerConfig`], but signers attest one + * outcome *index* of a categorical market instead of Yes/No. `outcome_count` + * binds the market's outcome set to the commitment (2..=[`MAX_CAT_OUTCOMES`], + * mirroring market-core's cap), so a reveal cannot silently change how many + * outcomes signers are voting on. + */ +export interface CatSignerConfig { + dispute_window: u64; + outcome_count: u32; + signers: Array; + threshold: u32; +} + export type ResolutionState = {tag: "Unconfigured", values: void} | {tag: "SignersPending", values: void} | {tag: "Finalized", values: void} | {tag: "Expired", values: void} | {tag: "Disputed", values: void}; @@ -138,6 +160,27 @@ network_id: Buffer; } +/** + * Scalar (range) counterpart of [`PriceConfig`]. The market settles on the + * fractional position of `price` inside `[lower, upper]`: + * `payout_long_bps = (clamped_price - lower) / (upper - lower)`, and the + * SHORT side pays the complement. A reading below the range clamps to + * `lower` (LONG pays 0), above to `upper` (LONG pays 1.0); a non-positive + * reading is an invalid price, never a clamp. + */ +export interface ScalarPriceConfig { + asset: OracleAsset; + /** + * Bottom of the staked range. Validated `0 <= lower < upper`, with + * `upper` capped so `upper * BPS` cannot overflow. + */ +lower: i128; + max_staleness: u64; + reflector: string; + upper: i128; +} + + export interface WeightedKeyConfig { dispute_window: u64; signers: Array; @@ -151,6 +194,8 @@ weight_threshold: u32; + + /** * Attested resolution whose proposal can be challenged with a bond during * the dispute window. Hashed as `ResolutionSpec::SignersDisputable`, a @@ -171,6 +216,7 @@ escalation_timeout: u64; threshold: u32; } + export type Outcome = {tag: "Yes", values: void} | {tag: "No", values: void}; export interface Client { @@ -198,6 +244,25 @@ export interface Client { */ dispute: ({market_id, disputer, alternative}: {market_id: u64, disputer: string, alternative: Option}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a attest_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical counterpart of `attest`: `signer` attests that + * `outcome_index` is the winner. `outcome_index` must be below the + * `outcome_count` the market's commitment fixed. Once `threshold` + * members have attested an index it becomes the proposal and the + * dispute window opens. + */ + attest_cat: ({market_id, signer, outcome_index}: {market_id: u64, signer: string, outcome_index: u32}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a finalize_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical counterpart of `finalize_signers`: once the dispute window + * of a proposal has passed and its index still has `threshold` + * attestations, resolves the market in market-core and returns the + * winning index. + */ + finalize_cat: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a current_round transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Current attestation round of a weighted-key market; the `round` @@ -214,8 +279,8 @@ export interface Client { * Resolution is preferred over voiding. A price market whose feed is * valid right now is *resolved* instead (so a stalled caller can never * force a void on a resolvable market), and a market with a pending - * signer proposal is refused with `ResolutionAvailable` until - * `finalize_signers` settles it. + * signer or categorical proposal is refused with `ResolutionAvailable` + * until it is finalized. */ expire_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -244,7 +309,8 @@ export interface Client { * evidence surfaced during the dispute window that it is wrong. For * address-based signers voting can restart once the counts are cleared by * re-attesting; a weighted-key market moves to a fresh round, which makes - * every signature collected so far unusable. + * every signature collected so far unusable. Works for both the binary + * and the categorical proposal slot. */ cancel_proposal: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -305,7 +371,7 @@ export interface Client { * Construct and simulate a resolution_deadline transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * When `expire_market` becomes callable for `market_id`: `close_time` plus * [`RESOLUTION_GRACE_SECS`]. Needs only market-core's `close_time`, so it - * is known before (and without) a spec reveal. + * is known before (and without) a spec reveal, for either market kind. */ resolution_deadline: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> @@ -317,12 +383,44 @@ export interface Client { */ guardian_void_market: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a register_scalar_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * See `register_price_spec` — same commit/reveal rule, scalar mode: + * the market settles fractionally from the feed price clamped into + * `[lower, upper]` (see `resolve_scalar_price`). + */ + register_scalar_spec: ({market_id, spec}: {market_id: u64, spec: ScalarPriceConfig}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a register_signer_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * See `register_price_spec` — same commit/reveal rule, signer mode. */ register_signer_spec: ({market_id, spec}: {market_id: u64, spec: SignerConfig}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a resolve_scalar_price transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Resolves a scalar market from its Reflector feed. The reading is + * clamped into the committed `[lower, upper]` range and settled as the + * fractional LONG payout `(clamped - lower) / (upper - lower)` in bps + * (SHORT pays the complement). Returns the payout. + * + * A non-positive reading is `InvalidPrice` rather than a clamp: it means + * the feed is broken, not that the value sits at the bottom of the + * range. Same close-time, staleness and terminal-state rules as + * `resolve_price`. + */ + resolve_scalar_price: ({market_id}: {market_id: u64}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a register_cat_signer_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * See `register_price_spec` — same commit/reveal rule, categorical + * outcome-index committee. Only meaningful for markets whose + * `resolution_hash` commits to `ResolutionSpec::CategoricalSigners` with + * this exact `outcome_count`; the reveal target's `resolution_hash` is + * read from whichever market kind owns the id. + */ + register_cat_signer_spec: ({market_id, spec}: {market_id: u64, spec: CatSignerConfig}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a register_disputable_spec transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * See `register_price_spec` — same commit/reveal rule, signer mode whose @@ -357,46 +455,56 @@ export class Client extends ContractClient { } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAGQAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAASAAAAAAAAABJEZWFkbGluZU5vdFJlYWNoZWQAAAAAABMAAAAAAAAAE1Jlc29sdXRpb25BdmFpbGFibGUAAAAAFAAAAAAAAAAORG9tYWluTWlzbWF0Y2gAAAAAABUAAAAAAAAAClN0YWxlUm91bmQAAAAAABYAAAAAAAAAEVRvb01hbnlTaWduYXR1cmVzAAAAAAAAFwAAAAAAAAATRGlzcHV0ZVdpbmRvd0Nsb3NlZAAAAAAYAAAAAAAAAAlOb0Rpc3B1dGUAAAAAAAAZ", + new ContractSpec([ "AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAAGgAAAAAAAAAOTm90SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADFVuYXV0aG9yaXplZAAAAAIAAAAAAAAADUludmFsaWRDb25maWcAAAAAAAADAAAAAAAAABdNYXJrZXRBbHJlYWR5Q29uZmlndXJlZAAAAAAEAAAAAAAAABNNYXJrZXROb3RDb25maWd1cmVkAAAAAAUAAAAAAAAACVdyb25nTW9kZQAAAAAAAAYAAAAAAAAAD01hcmtldE5vdENsb3NlZAAAAAAHAAAAAAAAABBQcmljZVVuYXZhaWxhYmxlAAAACAAAAAAAAAAKU3RhbGVQcmljZQAAAAAACQAAAAAAAAAMSW52YWxpZFByaWNlAAAACgAAAAAAAAAJTm90U2lnbmVyAAAAAAAACwAAAAAAAAAPQWxyZWFkeUF0dGVzdGVkAAAAAAwAAAAAAAAAE1RocmVzaG9sZE5vdFJlYWNoZWQAAAAADQAAAAAAAAANRGlzcHV0ZUFjdGl2ZQAAAAAAAA4AAAAAAAAAEURpc3B1dGVXaW5kb3dPcGVuAAAAAAAADwAAAAAAAAAKTm9Qcm9wb3NhbAAAAAAAEAAAAAAAAAAQQWxyZWFkeUZpbmFsaXplZAAAABEAAAAAAAAAEFNwZWNIYXNoTWlzbWF0Y2gAAAASAAAAAAAAABJEZWFkbGluZU5vdFJlYWNoZWQAAAAAABMAAAAAAAAAE1Jlc29sdXRpb25BdmFpbGFibGUAAAAAFAAAAAAAAAAORG9tYWluTWlzbWF0Y2gAAAAAABUAAAAAAAAAClN0YWxlUm91bmQAAAAAABYAAAAAAAAAEVRvb01hbnlTaWduYXR1cmVzAAAAAAAAFwAAAAAAAAATRGlzcHV0ZVdpbmRvd0Nsb3NlZAAAAAAYAAAAAAAAAAlOb0Rpc3B1dGUAAAAAAAAZAAAAQ2BhdHRlc3RfY2F0YCBvdXRjb21lIGluZGV4IG5vdCBiZWxvdyB0aGUgY29tbWl0dGVkIGBvdXRjb21lX2NvdW50YC4AAAAAE0ludmFsaWRPdXRjb21lSW5kZXgAAAAAGg==", "AAAAAgAAAD5UaGUgZXNjYWxhdGlvbiBhdXRob3JpdHkncyBydWxpbmcuIGBJbnZhbGlkYCB2b2lkcyB0aGUgbWFya2V0LgAAAAAAAAAAAAdWZXJkaWN0AAAAAAMAAAAAAAAAAAAAAANZZXMAAAAAAAAAAAAAAAACTm8AAAAAAAAAAAAAAAAAB0ludmFsaWQA", "AAAABQAAAAAAAAAAAAAACERpc3B1dGVkAAAAAQAAAAhkaXNwdXRlZAAAAAQAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAhkaXNwdXRlcgAAABMAAAAAAAAAAAAAAARib25kAAAACwAAAAAAAABJV2hhdCB0aGUgZGlzcHV0ZXIgY2xhaW1zIGlzIGNvcnJlY3Q7IGBOb25lYCBjbGFpbXMgdGhlIG1hcmtldCBpcyBpbnZhbGlkLgAAAAAAAAthbHRlcm5hdGl2ZQAAAAPoAAAH0AAAAAdPdXRjb21lAAAAAAAAAAAC", "AAAAAgAAABdXaG8gcnVsZXMgb24gYSBkaXNwdXRlLgAAAAAAAAAACkVzY2FsYXRpb24AAAAAAAIAAAAAAAAALVRoZSByZXNvbHZlciBhZG1pbiwgdGhyb3VnaCBgc2V0dGxlX2Rpc3B1dGVgLgAAAAAAAAhHdWFyZGlhbgAAAAEAAABfYGtgIG9mIHRoZXNlIG1lbWJlcnMsIHRocm91Z2ggYGVzY2FsYXRpb25fYXR0ZXN0YC4gTXVzdCBiZSBkaXNqb2ludApmcm9tIHRoZSBwcm9wb3Npbmcgc2lnbmVycy4AAAAACUNvbW1pdHRlZQAAAAAAAAIAAAPqAAAAEwAAAAQ=", - "AAAAAgAAADZXaGljaCByZXNvbHV0aW9uIHBhdGggYSBtYXJrZXQgd2FzIG9uIHdoZW4gaXQgZXhwaXJlZC4AAAAAAAAAAAAKRXhwaXJ5TW9kZQAAAAAAAwAAAAAAAAAAAAAAClVucmV2ZWFsZWQAAAAAAAAAAAAAAAAABVByaWNlAAAAAAAAAAAAAAAAAAAHU2lnbmVycwA=", + "AAAAAgAAADZXaGljaCByZXNvbHV0aW9uIHBhdGggYSBtYXJrZXQgd2FzIG9uIHdoZW4gaXQgZXhwaXJlZC4AAAAAAAAAAAAKRXhwaXJ5TW9kZQAAAAAABAAAAAAAAAAAAAAAClVucmV2ZWFsZWQAAAAAAAAAAAAAAAAABVByaWNlAAAAAAAAAAAAAAAAAAAHU2lnbmVycwAAAAAAAAAAKVNjYWxhciBjb3VudGVycGFydCBvZiBgUHJpY2VgIChhcHBlbmRlZCkuAAAAAAAABlNjYWxhcgAA", "AAAAAgAAAAAAAAAAAAAAC09yYWNsZUFzc2V0AAAAAAIAAAABAAAAAAAAAAdTdGVsbGFyAAAAAAEAAAATAAAAAQAAAAAAAAAFT3RoZXIAAAAAAAABAAAAEQ==", "AAAAAQAAAAAAAAAAAAAAC1ByaWNlQ29uZmlnAAAAAAUAAAAAAAAABWFzc2V0AAAAAAAH0AAAAAtPcmFjbGVBc3NldAAAAAAAAAAACWRpcmVjdGlvbgAAAAAAB9AAAAAOUHJpY2VEaXJlY3Rpb24AAAAAAAAAAAANbWF4X3N0YWxlbmVzcwAAAAAAAAYAAAAAAAAACXJlZmxlY3RvcgAAAAAAABMAAAAAAAAACXRocmVzaG9sZAAAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAC1dlaWdodGVkS2V5AAAAAAIAAACCUmF3IEVkMjU1MTkgcHVibGljIGtleS4gRm9yIGEgYEcuLi5gIGFjY291bnQgYWRkcmVzcyB0aGlzIGlzCmBTdHJLZXkuZGVjb2RlRWQyNTUxOVB1YmxpY0tleShhZGRyZXNzKWA7IGBDLi4uYCBhZGRyZXNzZXMgaGF2ZSBub25lLgAAAAAABnB1YmtleQAAAAAD7gAAACAAAAAAAAAABndlaWdodAAAAAAABA==", "AAAAAQAAAAAAAAAAAAAADFNpZ25lckNvbmZpZwAAAAMAAAAAAAAADmRpc3B1dGVfd2luZG93AAAAAAAGAAAAAAAAAAdzaWduZXJzAAAAA+oAAAATAAAAAAAAAAl0aHJlc2hvbGQAAAAAAAAE", "AAAABQAAAAAAAAAAAAAADU1hcmtldEV4cGlyZWQAAAAAAAABAAAADm1hcmtldF9leHBpcmVkAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAKY2xvc2VfdGltZQAAAAAABgAAAAAAAAAAAAAACGRlYWRsaW5lAAAABgAAAAAAAAAAAAAABG1vZGUAAAfQAAAACkV4cGlyeU1vZGUAAAAAAAAAAAAC", "AAAAAgAAAAAAAAAAAAAADlByaWNlRGlyZWN0aW9uAAAAAAAEAAAAAAAAAAAAAAAFQWJvdmUAAAAAAAAAAAAAAAAAAAlBdE9yQWJvdmUAAAAAAAAAAAAAAAAAAAVCZWxvdwAAAAAAAAAAAAAAAAAACUF0T3JCZWxvdwAAAA==", - "AAAAAgAAARNUaGUgZnVsbCByZXNvbHV0aW9uIHJ1bGUgYSBtYXJrZXQncyBgcmVzb2x1dGlvbl9oYXNoYCBjb21taXRzIHRvLiBIYXNoaW5nCnRoaXMgKHJhdGhlciB0aGFuIHRoZSBiYXJlIGBQcmljZUNvbmZpZ2AvYFNpZ25lckNvbmZpZ2ApIGtlZXBzIHRoZSB0d28KbW9kZXMgaW4gZGlzam9pbnQgaGFzaCBzcGFjZXMgZXZlbiBpZiB0aGVpciBmaWVsZCBlbmNvZGluZ3MgY291bGQKb3RoZXJ3aXNlIGNvbGxpZGUuIFNlZSBgZG9jcy9hZHIvMDAwMS1yZXNvbHV0aW9uLWNvbW1pdG1lbnQubWRgLgAAAAAAAAAADlJlc29sdXRpb25TcGVjAAAAAAAEAAAAAQAAAAAAAAAFUHJpY2UAAAAAAAABAAAH0AAAAAtQcmljZUNvbmZpZwAAAAABAAAAAAAAAAdTaWduZXJzAAAAAAEAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAAAAAAADFdlaWdodGVkS2V5cwAAAAEAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAAAAAAAARU2lnbmVyc0Rpc3B1dGFibGUAAAAAAAABAAAH0AAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAA=", + "AAAAAgAAAZZUaGUgZnVsbCByZXNvbHV0aW9uIHJ1bGUgYSBtYXJrZXQncyBgcmVzb2x1dGlvbl9oYXNoYCBjb21taXRzIHRvLiBIYXNoaW5nCnRoaXMgKHJhdGhlciB0aGFuIHRoZSBiYXJlIGBQcmljZUNvbmZpZ2AvYFNpZ25lckNvbmZpZ2ApIGtlZXBzIHRoZSB0d28KbW9kZXMgaW4gZGlzam9pbnQgaGFzaCBzcGFjZXMgZXZlbiBpZiB0aGVpciBmaWVsZCBlbmNvZGluZ3MgY291bGQKb3RoZXJ3aXNlIGNvbGxpZGUuIFNlZSBgZG9jcy9hZHIvMDAwMS1yZXNvbHV0aW9uLWNvbW1pdG1lbnQubWRgLgoKVmFyaWFudHMgYXJlIGFwcGVuZC1vbmx5OiB0aGUgWERSIHRhZyBpcyB0aGUgdmFyaWFudCBwb3NpdGlvbiwgc28gYSBuZXcKbW9kZSBnb2VzIGF0IHRoZSBlbmQgYW5kIGV4aXN0aW5nIGNvbW1pdG1lbnRzIG5ldmVyIG1vdmUuAAAAAAAAAAAADlJlc29sdXRpb25TcGVjAAAAAAAGAAAAAQAAAAAAAAAFUHJpY2UAAAAAAAABAAAH0AAAAAtQcmljZUNvbmZpZwAAAAABAAAAAAAAAAdTaWduZXJzAAAAAAEAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAAAAAAADFdlaWdodGVkS2V5cwAAAAEAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAAAAAAAARU2lnbmVyc0Rpc3B1dGFibGUAAAAAAAABAAAH0AAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAAAAAABAAAAN0NhdGVnb3JpY2FsIGNvdW50ZXJwYXJ0IG9mIGBTaWduZXJzYCAoYXBwZW5kZWQsIHRhZyA0KS4AAAAAEkNhdGVnb3JpY2FsU2lnbmVycwAAAAAAAQAAB9AAAAAPQ2F0U2lnbmVyQ29uZmlnAAAAAAEAAAB3U2NhbGFyIGNvdW50ZXJwYXJ0IG9mIGBQcmljZWAgKGFwcGVuZGVkLCB0YWcgNSk6IHRoZSBiaW5hcnkgcG9vbApzZXR0bGVzIGF0IGEgZnJhY3Rpb25hbCBMT05HIHBheW91dCBpbnN0ZWFkIG9mIGEgc2lkZS4AAAAABlNjYWxhcgAAAAAAAQAAB9AAAAARU2NhbGFyUHJpY2VDb25maWcAAAA=", "AAAABQAAAVlgZGlzcHV0ZXJfd29uYCBpcyB0cnVlIHdoZW4gdGhlIGJvbmQgcmV0dXJucyB0byB0aGUgZGlzcHV0ZXIgYmVjYXVzZSB0aGUKcnVsaW5nIG92ZXJ0dXJuZWQgdGhlIHByb3Bvc2FsLiBBIHRpbWVvdXQgKGBleHBpcmVfZGlzcHV0ZWApIGFsc28gcmVmdW5kcwp0aGUgYm9uZCBidXQgaXMgcmVwb3J0ZWQgYXMgYHZlcmRpY3Q6IEludmFsaWQsIGRpc3B1dGVyX3dvbjogZmFsc2VgOiBhCnJ1bGluZyBvZiBgSW52YWxpZGAgYWx3YXlzIG92ZXJ0dXJucywgc28gdGhhdCBwYWlyIGlkZW50aWZpZXMgYSB0aW1lb3V0LgpgcGF5b3V0YCBpcyB0aGUgZXNjcm93ZWQgYm9uZCwgY2xhaW1hYmxlIGJ5IGl0cyBwYXllZS4AAAAAAAAAAAAADkRpc3B1dGVTZXR0bGVkAAAAAAABAAAAD2Rpc3B1dGVfc2V0dGxlZAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAAAAAAAAAAAMZGlzcHV0ZXJfd29uAAAAAQAAAAAAAAAAAAAABnBheW91dAAAAAAACwAAAAAAAAAC", + "AAAAAQAAAUxDYXRlZ29yaWNhbCBzaWduZXIgY29tbWl0dGVlOiBsaWtlIFtgU2lnbmVyQ29uZmlnYF0sIGJ1dCBzaWduZXJzIGF0dGVzdCBvbmUKb3V0Y29tZSAqaW5kZXgqIG9mIGEgY2F0ZWdvcmljYWwgbWFya2V0IGluc3RlYWQgb2YgWWVzL05vLiBgb3V0Y29tZV9jb3VudGAKYmluZHMgdGhlIG1hcmtldCdzIG91dGNvbWUgc2V0IHRvIHRoZSBjb21taXRtZW50ICgyLi49W2BNQVhfQ0FUX09VVENPTUVTYF0sCm1pcnJvcmluZyBtYXJrZXQtY29yZSdzIGNhcCksIHNvIGEgcmV2ZWFsIGNhbm5vdCBzaWxlbnRseSBjaGFuZ2UgaG93IG1hbnkKb3V0Y29tZXMgc2lnbmVycyBhcmUgdm90aW5nIG9uLgAAAAAAAAAPQ2F0U2lnbmVyQ29uZmlnAAAAAAQAAAAAAAAADmRpc3B1dGVfd2luZG93AAAAAAAGAAAAAAAAAA1vdXRjb21lX2NvdW50AAAAAAAABAAAAAAAAAAHc2lnbmVycwAAAAPqAAAAEwAAAAAAAAAJdGhyZXNob2xkAAAAAAAABA==", "AAAAAgAAAAAAAAAAAAAAD1Jlc29sdXRpb25TdGF0ZQAAAAAFAAAAAAAAAAAAAAAMVW5jb25maWd1cmVkAAAAAAAAAAAAAAAOU2lnbmVyc1BlbmRpbmcAAAAAAAAAAAAAAAAACUZpbmFsaXplZAAAAAAAAAAAAABEVm9pZGVkIGJ5IFtgUmVzb2x2ZXI6OmV4cGlyZV9tYXJrZXRgXSBhZnRlciB0aGUgcmVzb2x1dGlvbiBkZWFkbGluZS4AAAAHRXhwaXJlZAAAAAAAAAAAQkEgYm9uZGVkIGRpc3B1dGUgaXMgb3BlbjsgZmluYWxpemF0aW9uIGlzIGZyb3plbiB1bnRpbCBpdCBzZXR0bGVzLgAAAAAACERpc3B1dGVk", "AAAABQAAAAAAAAAAAAAAD1ByaWNlUmVzb2x1dGlvbgAAAAABAAAAEHByaWNlX3Jlc29sdXRpb24AAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAAAAAAAAFcHJpY2UAAAAAAAALAAAAAAAAAAAAAAAPcHJpY2VfdGltZXN0YW1wAAAAAAYAAAAAAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAg==", "AAAAAQAAAOBWZXJzaW9uZWQgY29tbWl0bWVudCBlbnZlbG9wZS4gVW5saWtlIGEgYmFyZSBbYFJlc29sdXRpb25TcGVjYF0gaXQgYmluZHMgdGhlCm5ldHdvcmsgYW5kIHRoZSBtYXJrZXQtY29yZSBpbnN0YW5jZSwgYW5kIGNhcnJpZXMgYSBjcmVhdG9yLWNob3NlbiBgc2FsdGAKdGhhdCBtYWtlcyBhIHByZS1yZXZlYWwgYnJ1dGUtZm9yY2Ugb2YgdGhlIChsb3ctZW50cm9weSkgcnVsZSBpbmZlYXNpYmxlLgAAAAAAAAAQUmVzb2x1dGlvblNwZWNWMgAAAAUAAAAAAAAAC21hcmtldF9jb3JlAAAAABMAAAA/YHNoYTI1NihuZXR3b3JrIHBhc3NwaHJhc2UpYCwgaS5lLiBgZW52LmxlZGdlcigpLm5ldHdvcmtfaWQoKWAuAAAAAApuZXR3b3JrX2lkAAAAAAPuAAAAIAAAAAAAAAAEcnVsZQAAB9AAAAAOUmVzb2x1dGlvblNwZWMAAAAAAAAAAAAEc2FsdAAAA+4AAAAgAAAAAAAAAAd2ZXJzaW9uAAAAAAQ=", + "AAAAAQAAAX9TY2FsYXIgKHJhbmdlKSBjb3VudGVycGFydCBvZiBbYFByaWNlQ29uZmlnYF0uIFRoZSBtYXJrZXQgc2V0dGxlcyBvbiB0aGUKZnJhY3Rpb25hbCBwb3NpdGlvbiBvZiBgcHJpY2VgIGluc2lkZSBgW2xvd2VyLCB1cHBlcl1gOgpgcGF5b3V0X2xvbmdfYnBzID0gKGNsYW1wZWRfcHJpY2UgLSBsb3dlcikgLyAodXBwZXIgLSBsb3dlcilgLCBhbmQgdGhlClNIT1JUIHNpZGUgcGF5cyB0aGUgY29tcGxlbWVudC4gQSByZWFkaW5nIGJlbG93IHRoZSByYW5nZSBjbGFtcHMgdG8KYGxvd2VyYCAoTE9ORyBwYXlzIDApLCBhYm92ZSB0byBgdXBwZXJgIChMT05HIHBheXMgMS4wKTsgYSBub24tcG9zaXRpdmUKcmVhZGluZyBpcyBhbiBpbnZhbGlkIHByaWNlLCBuZXZlciBhIGNsYW1wLgAAAAAAAAAAEVNjYWxhclByaWNlQ29uZmlnAAAAAAAABQAAAAAAAAAFYXNzZXQAAAAAAAfQAAAAC09yYWNsZUFzc2V0AAAAAHFCb3R0b20gb2YgdGhlIHN0YWtlZCByYW5nZS4gVmFsaWRhdGVkIGAwIDw9IGxvd2VyIDwgdXBwZXJgLCB3aXRoCmB1cHBlcmAgY2FwcGVkIHNvIGB1cHBlciAqIEJQU2AgY2Fubm90IG92ZXJmbG93LgAAAAAAAAVsb3dlcgAAAAAAAAsAAAAAAAAADW1heF9zdGFsZW5lc3MAAAAAAAAGAAAAAAAAAAlyZWZsZWN0b3IAAAAAAAATAAAAAAAAAAV1cHBlcgAAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAAB3NpZ25lcnMAAAAD6gAAB9AAAAALV2VpZ2h0ZWRLZXkAAAAAd1RvdGFsIHdlaWdodCB0aGF0IG11c3Qgc2lnbiBvbmUgb3V0Y29tZS4gQ2hvb3NlIGl0IGFib3ZlIGhhbGYgdGhlIHRvdGFsCndlaWdodCBzbyB0d28gb3V0Y29tZXMgY2FuIG5ldmVyIGJvdGggcmVhY2ggaXQuAAAAABB3ZWlnaHRfdGhyZXNob2xkAAAABA==", "AAAABQAAAAAAAAAAAAAAElJlc29sdXRpb25Qcm9wb3NlZAAAAAAAAQAAABNyZXNvbHV0aW9uX3Byb3Bvc2VkAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAtwcm9wb3NlZF9hdAAAAAAGAAAAAAAAAAI=", "AAAABQAAAAAAAAAAAAAAE1Jlc29sdXRpb25GaW5hbGl6ZWQAAAAAAQAAABRyZXNvbHV0aW9uX2ZpbmFsaXplZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAdvdXRjb21lAAAAB9AAAAAHT3V0Y29tZQAAAAAAAAAAAAAAAAxmaW5hbGl6ZWRfYXQAAAAGAAAAAAAAAAI=", + "AAAABQAAADBDYXRlZ29yaWNhbCBjb3VudGVycGFydCBvZiBgUmVzb2x1dGlvblByb3Bvc2VkYC4AAAAAAAAAFUNhdFJlc29sdXRpb25Qcm9wb3NlZAAAAAAAAAEAAAAXY2F0X3Jlc29sdXRpb25fcHJvcG9zZWQAAAAAAwAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAAAAAAADW91dGNvbWVfaW5kZXgAAAAAAAAEAAAAAAAAAAAAAAALcHJvcG9zZWRfYXQAAAAABgAAAAAAAAAC", + "AAAABQAAAG9TY2FsYXIgY291bnRlcnBhcnQgb2YgYFByaWNlUmVzb2x1dGlvbmA6IHRoZSBzZXR0bGVkIG1hcmtldCBjYXJyaWVzIGEKZnJhY3Rpb25hbCBMT05HIHBheW91dCBpbnN0ZWFkIG9mIGEgc2lkZS4AAAAAAAAAABVTY2FsYXJQcmljZVJlc29sdXRpb24AAAAAAAABAAAAF3NjYWxhcl9wcmljZV9yZXNvbHV0aW9uAAAAAAQAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAAVwcmljZQAAAAAAAAsAAAAAAAAAAAAAAA9wcmljZV90aW1lc3RhbXAAAAAABgAAAAAAAAAAAAAAD3BheW91dF9sb25nX2JwcwAAAAAEAAAAAAAAAAI=", "AAAAAQAAANBBdHRlc3RlZCByZXNvbHV0aW9uIHdob3NlIHByb3Bvc2FsIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIGR1cmluZwp0aGUgZGlzcHV0ZSB3aW5kb3cuIEhhc2hlZCBhcyBgUmVzb2x1dGlvblNwZWM6OlNpZ25lcnNEaXNwdXRhYmxlYCwgYQpzZXBhcmF0ZSB2YXJpYW50LCBzbyBleGlzdGluZyBgU2lnbmVyc2AgY29tbWl0bWVudHMgYXJlIHVuYWZmZWN0ZWQuAAAAAAAAABZEaXNwdXRhYmxlU2lnbmVyQ29uZmlnAAAAAAAGAAAAM0NvbGxhdGVyYWwgZXNjcm93ZWQgYnkgYGRpc3B1dGVgOyBtdXN0IGJlIHBvc2l0aXZlLgAAAAAMZGlzcHV0ZV9ib25kAAAACwAAAAAAAAAOZGlzcHV0ZV93aW5kb3cAAAAAAAYAAAAAAAAACmVzY2FsYXRpb24AAAAAB9AAAAAKRXNjYWxhdGlvbgAAAAAAQ1NlY29uZHMgYWZ0ZXIgYSBkaXNwdXRlIG9wZW5zIGJlZm9yZSBhbnlvbmUgbWF5IHZvaWQgaXQgdW5yZXNvbHZlZC4AAAAAEmVzY2FsYXRpb25fdGltZW91dAAAAAAABgAAAAAAAAAHc2lnbmVycwAAAAPqAAAAEwAAAAAAAAAJdGhyZXNob2xkAAAAAAAABA==", + "AAAABQAAADFDYXRlZ29yaWNhbCBjb3VudGVycGFydCBvZiBgUmVzb2x1dGlvbkZpbmFsaXplZGAuAAAAAAAAAAAAABZDYXRSZXNvbHV0aW9uRmluYWxpemVkAAAAAAABAAAAGGNhdF9yZXNvbHV0aW9uX2ZpbmFsaXplZAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAAAAAAAA1vdXRjb21lX2luZGV4AAAAAAAABAAAAAAAAAAAAAAADGZpbmFsaXplZF9hdAAAAAYAAAAAAAAAAg==", "AAAAAgAAAAAAAAAAAAAAB091dGNvbWUAAAAAAgAAAAAAAAAAAAAAA1llcwAAAAAAAAAAAAAAAAJObwAA", "AAAAAAAAAAAAAAAFc3RhdGUAAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAB9AAAAAPUmVzb2x1dGlvblN0YXRlAA==", "AAAAAAAAAAAAAAAGYXR0ZXN0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAi5DaGFsbGVuZ2VzIHRoZSBjdXJyZW50IHByb3Bvc2FsIG9mIGEgYFNpZ25lcnNEaXNwdXRhYmxlYCBtYXJrZXQgd2hpbGUKaXRzIGRpc3B1dGUgd2luZG93IGlzIHN0aWxsIG9wZW4gKHN0cmljdGx5IGJlZm9yZSBgcHJvcG9zZWRfYXQgKwpkaXNwdXRlX3dpbmRvd2AsIHRoZSBpbnN0YW50IGBmaW5hbGl6ZV9zaWduZXJzYCBiZWNvbWVzIGNhbGxhYmxlKS4gVGhlCmRpc3B1dGVyIGVzY3Jvd3MgYGRpc3B1dGVfYm9uZGAgb2YgdGhlIGNvbGxhdGVyYWwgdG9rZW4sIHdoaWNoIGZyZWV6ZXMKZmluYWxpemF0aW9uIHVudGlsIHRoZSBlc2NhbGF0aW9uIGF1dGhvcml0eSBydWxlcyBvcgpgZXNjYWxhdGlvbl90aW1lb3V0YCBsYXBzZXMuIGBhbHRlcm5hdGl2ZWAgaXMgdGhlIG91dGNvbWUgdGhlIGRpc3B1dGVyCmNsYWltcyBpcyBjb3JyZWN0IChgTm9uZWA6IHRoZSBtYXJrZXQgaXMgaW52YWxpZCk7IGl0IG9ubHkgaW5mb3JtcyB0aGUKcnVsaW5nIGFuZCBtdXN0IGRpZmZlciBmcm9tIHRoZSBwcm9wb3NhbC4gVGhlIGRpc3B1dGVyIG1heSBhbHNvIGJlIGEKcHJvcG9zaW5nIHNpZ25lci4AAAAAAAdkaXNwdXRlAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAACGRpc3B1dGVyAAAAEwAAAAAAAAALYWx0ZXJuYXRpdmUAAAAD6AAAB9AAAAAHT3V0Y29tZQAAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAARBDYXRlZ29yaWNhbCBjb3VudGVycGFydCBvZiBgYXR0ZXN0YDogYHNpZ25lcmAgYXR0ZXN0cyB0aGF0CmBvdXRjb21lX2luZGV4YCBpcyB0aGUgd2lubmVyLiBgb3V0Y29tZV9pbmRleGAgbXVzdCBiZSBiZWxvdyB0aGUKYG91dGNvbWVfY291bnRgIHRoZSBtYXJrZXQncyBjb21taXRtZW50IGZpeGVkLiBPbmNlIGB0aHJlc2hvbGRgCm1lbWJlcnMgaGF2ZSBhdHRlc3RlZCBhbiBpbmRleCBpdCBiZWNvbWVzIHRoZSBwcm9wb3NhbCBhbmQgdGhlCmRpc3B1dGUgd2luZG93IG9wZW5zLgAAAAphdHRlc3RfY2F0AAAAAAADAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAAZzaWduZXIAAAAAABMAAAAAAAAADW91dGNvbWVfaW5kZXgAAAAAAAAEAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAANNDYXRlZ29yaWNhbCBjb3VudGVycGFydCBvZiBgZmluYWxpemVfc2lnbmVyc2A6IG9uY2UgdGhlIGRpc3B1dGUgd2luZG93Cm9mIGEgcHJvcG9zYWwgaGFzIHBhc3NlZCBhbmQgaXRzIGluZGV4IHN0aWxsIGhhcyBgdGhyZXNob2xkYAphdHRlc3RhdGlvbnMsIHJlc29sdmVzIHRoZSBtYXJrZXQgaW4gbWFya2V0LWNvcmUgYW5kIHJldHVybnMgdGhlCndpbm5pbmcgaW5kZXguAAAAAAxmaW5hbGl6ZV9jYXQAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAAEAAAAAw==", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAALbWFya2V0X2NvcmUAAAAAEwAAAAA=", "AAAAAAAAAIFDdXJyZW50IGF0dGVzdGF0aW9uIHJvdW5kIG9mIGEgd2VpZ2h0ZWQta2V5IG1hcmtldDsgdGhlIGByb3VuZGAKYXJndW1lbnQgb2YgYHN1Ym1pdF9zaWduYXR1cmVzYCBhbmQgcGFydCBvZiBldmVyeSBzaWduZWQgbWVzc2FnZS4AAAAAAAANY3VycmVudF9yb3VuZAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAABA==", - "AAAAAAAAAfdQZXJtaXNzaW9ubGVzcyBsaXZlbmVzcyBmYWxsYmFjazogb25jZSBgcmVzb2x1dGlvbl9kZWFkbGluZWAgaGFzIHBhc3NlZCwKYW55b25lIG1heSB2b2lkIGEgbWFya2V0IHRoYXQgaXMgc3RpbGwgdW5yZXNvbHZlZCwgc28gY29sbGF0ZXJhbCBpcwpuZXZlciBsb2NrZWQgYmVoaW5kIGEgbWlzc2luZyByZXZlYWwsIGEgZGVhZCBmZWVkIG9yIHNpbGVudCBzaWduZXJzLgoKUmVzb2x1dGlvbiBpcyBwcmVmZXJyZWQgb3ZlciB2b2lkaW5nLiBBIHByaWNlIG1hcmtldCB3aG9zZSBmZWVkIGlzCnZhbGlkIHJpZ2h0IG5vdyBpcyAqcmVzb2x2ZWQqIGluc3RlYWQgKHNvIGEgc3RhbGxlZCBjYWxsZXIgY2FuIG5ldmVyCmZvcmNlIGEgdm9pZCBvbiBhIHJlc29sdmFibGUgbWFya2V0KSwgYW5kIGEgbWFya2V0IHdpdGggYSBwZW5kaW5nCnNpZ25lciBwcm9wb3NhbCBpcyByZWZ1c2VkIHdpdGggYFJlc29sdXRpb25BdmFpbGFibGVgIHVudGlsCmBmaW5hbGl6ZV9zaWduZXJzYCBzZXR0bGVzIGl0LgAAAAANZXhwaXJlX21hcmtldAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAfhQZXJtaXNzaW9ubGVzcyBsaXZlbmVzcyBmYWxsYmFjazogb25jZSBgcmVzb2x1dGlvbl9kZWFkbGluZWAgaGFzIHBhc3NlZCwKYW55b25lIG1heSB2b2lkIGEgbWFya2V0IHRoYXQgaXMgc3RpbGwgdW5yZXNvbHZlZCwgc28gY29sbGF0ZXJhbCBpcwpuZXZlciBsb2NrZWQgYmVoaW5kIGEgbWlzc2luZyByZXZlYWwsIGEgZGVhZCBmZWVkIG9yIHNpbGVudCBzaWduZXJzLgoKUmVzb2x1dGlvbiBpcyBwcmVmZXJyZWQgb3ZlciB2b2lkaW5nLiBBIHByaWNlIG1hcmtldCB3aG9zZSBmZWVkIGlzCnZhbGlkIHJpZ2h0IG5vdyBpcyAqcmVzb2x2ZWQqIGluc3RlYWQgKHNvIGEgc3RhbGxlZCBjYWxsZXIgY2FuIG5ldmVyCmZvcmNlIGEgdm9pZCBvbiBhIHJlc29sdmFibGUgbWFya2V0KSwgYW5kIGEgbWFya2V0IHdpdGggYSBwZW5kaW5nCnNpZ25lciBvciBjYXRlZ29yaWNhbCBwcm9wb3NhbCBpcyByZWZ1c2VkIHdpdGggYFJlc29sdXRpb25BdmFpbGFibGVgCnVudGlsIGl0IGlzIGZpbmFsaXplZC4AAAANZXhwaXJlX21hcmtldAAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAAAAAAANcmVzb2x2ZV9wcmljZQAAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", "AAAAAAAAAMFQZXJtaXNzaW9ubGVzcyBmYWxsYmFjayB3aGVuIHRoZSBlc2NhbGF0aW9uIGF1dGhvcml0eSBuZXZlciBydWxlczoKYGVzY2FsYXRpb25fdGltZW91dGAgYWZ0ZXIgdGhlIGRpc3B1dGUgb3BlbmVkLCB0aGUgYm9uZCBpcyByZWZ1bmRlZCBhbmQKdGhlIG1hcmtldCBpcyB2b2lkZWQsIHNvIG5laXRoZXIgc2lkZSB3aW5zIGJ5IGRlZmF1bHQuAAAAAAAADmV4cGlyZV9kaXNwdXRlAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAADhHdWFyZGlhbiBlc2NhbGF0aW9uOiB0aGUgYWRtaW4gcnVsZXMgb24gYW4gb3BlbiBkaXNwdXRlLgAAAA5zZXR0bGVfZGlzcHV0ZQAAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAAAAAURHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIEZvcgphZGRyZXNzLWJhc2VkIHNpZ25lcnMgdm90aW5nIGNhbiByZXN0YXJ0IG9uY2UgdGhlIGNvdW50cyBhcmUgY2xlYXJlZCBieQpyZS1hdHRlc3Rpbmc7IGEgd2VpZ2h0ZWQta2V5IG1hcmtldCBtb3ZlcyB0byBhIGZyZXNoIHJvdW5kLCB3aGljaCBtYWtlcwpldmVyeSBzaWduYXR1cmUgY29sbGVjdGVkIHNvIGZhciB1bnVzYWJsZS4AAAAPY2FuY2VsX3Byb3Bvc2FsAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAYFHdWFyZGlhbiBhY3Rpb246IHdpdGhkcmF3cyBhbiB1bmZpbmFsaXplZCBzaWduZXIgcHJvcG9zYWwsIGUuZy4gYmVjYXVzZQpldmlkZW5jZSBzdXJmYWNlZCBkdXJpbmcgdGhlIGRpc3B1dGUgd2luZG93IHRoYXQgaXQgaXMgd3JvbmcuIEZvcgphZGRyZXNzLWJhc2VkIHNpZ25lcnMgdm90aW5nIGNhbiByZXN0YXJ0IG9uY2UgdGhlIGNvdW50cyBhcmUgY2xlYXJlZCBieQpyZS1hdHRlc3Rpbmc7IGEgd2VpZ2h0ZWQta2V5IG1hcmtldCBtb3ZlcyB0byBhIGZyZXNoIHJvdW5kLCB3aGljaCBtYWtlcwpldmVyeSBzaWduYXR1cmUgY29sbGVjdGVkIHNvIGZhciB1bnVzYWJsZS4gV29ya3MgZm9yIGJvdGggdGhlIGJpbmFyeQphbmQgdGhlIGNhdGVnb3JpY2FsIHByb3Bvc2FsIHNsb3QuAAAAAAAAD2NhbmNlbF9wcm9wb3NhbAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAAAAAAAQZmluYWxpemVfc2lnbmVycwAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAB9AAAAAHT3V0Y29tZQAAAAAD", "AAAAAAAAAWlSZXZlYWxzIGEgcnVsZSBjb21taXR0ZWQgdG8gdGhyb3VnaCBhIFtgUmVzb2x1dGlvblNwZWNWMmBdIGVudmVsb3BlLgpUaGUgZW52ZWxvcGUgbXVzdCBuYW1lIHRoaXMgbmV0d29yayBhbmQgdGhpcyByZXNvbHZlcidzIG1hcmtldC1jb3JlCihgRG9tYWluTWlzbWF0Y2hgKSwgdXNlIGEgbm9uLXplcm8gc2FsdCBhbmQgdGhlIGN1cnJlbnQgYHZlcnNpb25gCihgSW52YWxpZENvbmZpZ2ApLCBhbmQgaGFzaCB0byB0aGUgbWFya2V0J3MgY29tbWl0bWVudAooYFNwZWNIYXNoTWlzbWF0Y2hgKS4gVGhlIGlubmVyIHJ1bGUgaXMgdGhlbiB2YWxpZGF0ZWQgYW5kIHN0b3JlZApleGFjdGx5IGFzIHRoZSBsZWdhY3kgZW50cnlwb2ludHMgZG8uAAAAAAAAEHJlZ2lzdGVyX3NwZWNfdjIAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAARzcGVjAAAH0AAAABBSZXNvbHV0aW9uU3BlY1YyAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAGhDb21taXR0ZWUgZXNjYWxhdGlvbjogZWFjaCBtZW1iZXIgdm90ZXMgb25jZTsgdGhlIGZpcnN0IHZlcmRpY3QgdG8KZ2F0aGVyIGBrYCB2b3RlcyBzZXR0bGVzIHRoZSBkaXNwdXRlLgAAABFlc2NhbGF0aW9uX2F0dGVzdAAAAAAAAAMAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABm1lbWJlcgAAAAAAEwAAAAAAAAAHdmVyZGljdAAAAAfQAAAAB1ZlcmRpY3QAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAhRDb3VudHMgRWQyNTUxOSBzaWduYXR1cmVzIGZyb20gYSB3ZWlnaHRlZCBjb21taXR0ZWUuIFBlcm1pc3Npb25sZXNzOiB0aGUKc2lnbmF0dXJlcywgbm90IHRoZSBzdWJtaXR0ZXIsIGNhcnJ5IHRoZSBhdXRob3JpdHkuIEVhY2ggc2lnbmF0dXJlIGlzCm92ZXIgdGhlIG1lc3NhZ2UgYnVpbHQgYnkgYGF0dGVzdGF0aW9uX21lc3NhZ2VgOyBub24tbWVtYmVycyBhbmQga2V5cwp0aGF0IGFscmVhZHkgc2lnbmVkIHRoaXMgcm91bmQgKGZvciBlaXRoZXIgb3V0Y29tZSkgYXJlIHNraXBwZWQsIGFuZCBhCnNpbmdsZSBpbnZhbGlkIHNpZ25hdHVyZSBhYm9ydHMgdGhlIHdob2xlIGJhdGNoLCBzbyBzdWJtaXR0ZXJzIHNob3VsZApwcmUtZmlsdGVyIHdpdGggYSB2ZXJpZmllci4gT25jZSB0aGUgYWNjdW11bGF0ZWQgd2VpZ2h0IGZvciBgb3V0Y29tZWAKZmlyc3QgcmVhY2hlcyB0aGUgdGhyZXNob2xkIGEgcHJvcG9zYWwgaXMgcmVjb3JkZWQgYW5kIHRoZSBkaXNwdXRlCndpbmRvdyBzdGFydHM7IGxhdGVyIGNhbGxzIG5ldmVyIG1vdmUgaXQuAAAAEXN1Ym1pdF9zaWduYXR1cmVzAAAAAAAABAAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAAVyb3VuZAAAAAAAAAQAAAAAAAAABHNpZ3MAAAPqAAAD7QAAAAIAAAPuAAAAIAAAA+4AAABAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAIRQYXlzIGEgc2V0dGxlZCBkaXNwdXRlJ3MgYm9uZCB0byBpdHMgcGF5ZWUgKHRoZSBkaXNwdXRlciwgb3IgdGhlCm1hcmtldC1jb3JlIHRyZWFzdXJ5IHdoZW4gdGhlIHByb3Bvc2FsIHdhcyB1cGhlbGQpLiBQZXJtaXNzaW9ubGVzcy4AAAASY2xhaW1fZGlzcHV0ZV9ib25kAAAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAATpSZXZlYWxzIHRoZSByZXNvbHV0aW9uIHJ1bGUgY29tbWl0dGVkIHRvIGF0IG1hcmtldCBjcmVhdGlvbi4gQW55b25lCm1heSBjYWxsIHRoaXMgKHRoZXJlIGlzIG5vIGFkbWluIGdhdGUpIGJlY2F1c2UgdGhlIG9ubHkgdGhpbmcgdGhhdAptYWtlcyBhIHNwZWMgYWNjZXB0YWJsZSBpcyB0aGF0IGl0IGhhc2hlcyB0byBgbWFya2V0LnJlc29sdXRpb25faGFzaGAK4oCUIHRoZSBjb21taXRtZW50LCBub3QgdGhlIGNhbGxlciwgaXMgdGhlIGF1dGhvcml0eS4gRmFpbHMgaWYgYSBzcGVjCndhcyBhbHJlYWR5IHJlZ2lzdGVyZWQgZm9yIHRoaXMgbWFya2V0LgAAAAAAE3JlZ2lzdGVyX3ByaWNlX3NwZWMAAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAALUHJpY2VDb25maWcAAAAAAQAAA+kAAAACAAAAAw==", - "AAAAAAAAAL1XaGVuIGBleHBpcmVfbWFya2V0YCBiZWNvbWVzIGNhbGxhYmxlIGZvciBgbWFya2V0X2lkYDogYGNsb3NlX3RpbWVgIHBsdXMKW2BSRVNPTFVUSU9OX0dSQUNFX1NFQ1NgXS4gTmVlZHMgb25seSBtYXJrZXQtY29yZSdzIGBjbG9zZV90aW1lYCwgc28gaXQKaXMga25vd24gYmVmb3JlIChhbmQgd2l0aG91dCkgYSBzcGVjIHJldmVhbC4AAAAAAAATcmVzb2x1dGlvbl9kZWFkbGluZQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==", + "AAAAAAAAANVXaGVuIGBleHBpcmVfbWFya2V0YCBiZWNvbWVzIGNhbGxhYmxlIGZvciBgbWFya2V0X2lkYDogYGNsb3NlX3RpbWVgIHBsdXMKW2BSRVNPTFVUSU9OX0dSQUNFX1NFQ1NgXS4gTmVlZHMgb25seSBtYXJrZXQtY29yZSdzIGBjbG9zZV90aW1lYCwgc28gaXQKaXMga25vd24gYmVmb3JlIChhbmQgd2l0aG91dCkgYSBzcGVjIHJldmVhbCwgZm9yIGVpdGhlciBtYXJrZXQga2luZC4AAAAAAAATcmVzb2x1dGlvbl9kZWFkbGluZQAAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAAGAAAAAw==", "AAAAAAAAAMJHdWFyZGlhbiBhY3Rpb246IHZvaWRzIGFuIHVucmVzb2x2YWJsZSBtYXJrZXQgZGlyZWN0bHkgdGhyb3VnaAptYXJrZXQtY29yZS4gVGhlIHJlc29sdmVyIGlzIHRoZSBkaXJlY3QgY2FsbGVyLCBzbyBtYXJrZXQtY29yZSdzCmByZXF1aXJlX3Jlc29sdmVyYCBjaGVjayBwYXNzZXMgdGhlIHNhbWUgd2F5IGByZXNvbHZlX3ByaWNlYCBkb2VzLgAAAAAAFGd1YXJkaWFuX3ZvaWRfbWFya2V0AAAAAQAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAALNTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2NhbGFyIG1vZGU6CnRoZSBtYXJrZXQgc2V0dGxlcyBmcmFjdGlvbmFsbHkgZnJvbSB0aGUgZmVlZCBwcmljZSBjbGFtcGVkIGludG8KYFtsb3dlciwgdXBwZXJdYCAoc2VlIGByZXNvbHZlX3NjYWxhcl9wcmljZWApLgAAAAAUcmVnaXN0ZXJfc2NhbGFyX3NwZWMAAAACAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAARzcGVjAAAH0AAAABFTY2FsYXJQcmljZUNvbmZpZwAAAAAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAENTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUuAAAAABRyZWdpc3Rlcl9zaWduZXJfc3BlYwAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAADFNpZ25lckNvbmZpZwAAAAEAAAPpAAAAAgAAAAM=", + "AAAAAAAAAdJSZXNvbHZlcyBhIHNjYWxhciBtYXJrZXQgZnJvbSBpdHMgUmVmbGVjdG9yIGZlZWQuIFRoZSByZWFkaW5nIGlzCmNsYW1wZWQgaW50byB0aGUgY29tbWl0dGVkIGBbbG93ZXIsIHVwcGVyXWAgcmFuZ2UgYW5kIHNldHRsZWQgYXMgdGhlCmZyYWN0aW9uYWwgTE9ORyBwYXlvdXQgYChjbGFtcGVkIC0gbG93ZXIpIC8gKHVwcGVyIC0gbG93ZXIpYCBpbiBicHMKKFNIT1JUIHBheXMgdGhlIGNvbXBsZW1lbnQpLiBSZXR1cm5zIHRoZSBwYXlvdXQuCgpBIG5vbi1wb3NpdGl2ZSByZWFkaW5nIGlzIGBJbnZhbGlkUHJpY2VgIHJhdGhlciB0aGFuIGEgY2xhbXA6IGl0IG1lYW5zCnRoZSBmZWVkIGlzIGJyb2tlbiwgbm90IHRoYXQgdGhlIHZhbHVlIHNpdHMgYXQgdGhlIGJvdHRvbSBvZiB0aGUKcmFuZ2UuIFNhbWUgY2xvc2UtdGltZSwgc3RhbGVuZXNzIGFuZCB0ZXJtaW5hbC1zdGF0ZSBydWxlcyBhcwpgcmVzb2x2ZV9wcmljZWAuAAAAAAAUcmVzb2x2ZV9zY2FsYXJfcHJpY2UAAAABAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAQAAA+kAAAAEAAAAAw==", + "AAAAAAAAATZTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgY2F0ZWdvcmljYWwKb3V0Y29tZS1pbmRleCBjb21taXR0ZWUuIE9ubHkgbWVhbmluZ2Z1bCBmb3IgbWFya2V0cyB3aG9zZQpgcmVzb2x1dGlvbl9oYXNoYCBjb21taXRzIHRvIGBSZXNvbHV0aW9uU3BlYzo6Q2F0ZWdvcmljYWxTaWduZXJzYCB3aXRoCnRoaXMgZXhhY3QgYG91dGNvbWVfY291bnRgOyB0aGUgcmV2ZWFsIHRhcmdldCdzIGByZXNvbHV0aW9uX2hhc2hgIGlzCnJlYWQgZnJvbSB3aGljaGV2ZXIgbWFya2V0IGtpbmQgb3ducyB0aGUgaWQuAAAAAAAYcmVnaXN0ZXJfY2F0X3NpZ25lcl9zcGVjAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAAPQ2F0U2lnbmVyQ29uZmlnAAAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAIFTZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgc2lnbmVyIG1vZGUgd2hvc2UKcHJvcG9zYWxzIGNhbiBiZSBjaGFsbGVuZ2VkIHdpdGggYSBib25kIChzZWUgYGRpc3B1dGVgKS4AAAAAAAAYcmVnaXN0ZXJfZGlzcHV0YWJsZV9zcGVjAAAAAgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAEc3BlYwAAB9AAAAAWRGlzcHV0YWJsZVNpZ25lckNvbmZpZwAAAAAAAQAAA+kAAAACAAAAAw==", "AAAAAAAAAI5TZWUgYHJlZ2lzdGVyX3ByaWNlX3NwZWNgIOKAlCBzYW1lIGNvbW1pdC9yZXZlYWwgcnVsZSwgd2VpZ2h0ZWQgRWQyNTUxOQpjb21taXR0ZWUgdGhhdCBzaWducyBvZmYtY2hhaW4gYW5kIGlzIHZlcmlmaWVkIGJ5IGBzdWJtaXRfc2lnbmF0dXJlc2AuAAAAAAAacmVnaXN0ZXJfd2VpZ2h0ZWRfa2V5X3NwZWMAAAAAAAIAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAABHNwZWMAAAfQAAAAEVdlaWdodGVkS2V5Q29uZmlnAAAAAAAAAQAAA+kAAAACAAAAAw==" ]), options @@ -406,6 +514,8 @@ export class Client extends ContractClient { state: this.txFromJSON, attest: this.txFromJSON>, dispute: this.txFromJSON>, + attest_cat: this.txFromJSON>, + finalize_cat: this.txFromJSON>, current_round: this.txFromJSON, expire_market: this.txFromJSON>, resolve_price: this.txFromJSON>, @@ -420,7 +530,10 @@ export class Client extends ContractClient { register_price_spec: this.txFromJSON>, resolution_deadline: this.txFromJSON>, guardian_void_market: this.txFromJSON>, + register_scalar_spec: this.txFromJSON>, register_signer_spec: this.txFromJSON>, + resolve_scalar_price: this.txFromJSON>, + register_cat_signer_spec: this.txFromJSON>, register_disputable_spec: this.txFromJSON>, register_weighted_key_spec: this.txFromJSON> } diff --git a/oracledesk-stellar/packages/bindings/treasury/src/index.ts b/oracledesk-stellar/packages/bindings/treasury/src/index.ts index 118fa42..6f01244 100644 --- a/oracledesk-stellar/packages/bindings/treasury/src/index.ts +++ b/oracledesk-stellar/packages/bindings/treasury/src/index.ts @@ -137,6 +137,15 @@ export interface Client { */ agent_buy_v2: ({market_id, outcome, collateral_in, min_shares_out, limit_price_bps, deadline}: {market_id: u64, outcome: Outcome, collateral_in: i128, min_shares_out: i128, limit_price_bps: u32, deadline: u64}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a agent_buy_cat transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Categorical counterpart of `agent_buy`: buys `outcome_index` shares of + * a categorical market using treasury capital. Shares the per-trade and + * per-market caps with the binary path, and needs the same nested + * transfer pre-authorization (see `do_agent_buy`). + */ + agent_buy_cat: ({market_id, outcome_index, collateral_in, min_shares_out}: {market_id: u64, outcome_index: u32, collateral_in: i128, min_shares_out: i128}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a agent_sell_v2 transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * `agent_sell` with a per-share limit price (`limit_price_bps`, 0 = no @@ -247,6 +256,7 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAALZGFpbHlfdXNhZ2UAAAAAAAAAAAEAAAPpAAAH0AAAAApEYWlseVVzYWdlAAAAAAAD", "AAAAAAAAARdgYWdlbnRfYnV5YCB3aXRoIGEgcGVyLXNoYXJlIGxpbWl0IHByaWNlIChgbGltaXRfcHJpY2VfYnBzYCwgMCA9IG5vbmUpCmFuZCBhIGRlYWRsaW5lICh1bml4IHNlY29uZHMsIG5ldHdvcmsgdGltZSkuIG1hcmtldC1jb3JlJ3MgYGJ1eV92MmAKZW5mb3JjZXMgYm90aDsgYW4gZXhwaXJlZCBvciBvdXQtb2YtbGltaXQgdHJhZGUgcmV2ZXJ0cyB0aGUgd2hvbGUKaW52b2NhdGlvbiwgc28gdGhlIHRyZWFzdXJ5J3MgY2FwcyBhcmUgbmV2ZXIgY29uc3VtZWQgYnkgYSByZXZlcnRlZAp0cmFkZS4AAAAADGFnZW50X2J1eV92MgAAAAYAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAAAAAAAB291dGNvbWUAAAAH0AAAAAdPdXRjb21lAAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAAAAAAADm1pbl9zaGFyZXNfb3V0AAAAAAALAAAAAAAAAA9saW1pdF9wcmljZV9icHMAAAAABAAAAAAAAAAIZGVhZGxpbmUAAAAGAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAcAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAFYWdlbnQAAAAAAAATAAAAAAAAAApjb2xsYXRlcmFsAAAAAAATAAAAAAAAAAttYXJrZXRfY29yZQAAAAATAAAAAAAAAAltYXhfdHJhZGUAAAAAAAALAAAAAAAAAAptYXhfbWFya2V0AAAAAAALAAAAAAAAAAltYXhfZGFpbHkAAAAAAAALAAAAAA==", + "AAAAAAAAAP1DYXRlZ29yaWNhbCBjb3VudGVycGFydCBvZiBgYWdlbnRfYnV5YDogYnV5cyBgb3V0Y29tZV9pbmRleGAgc2hhcmVzIG9mCmEgY2F0ZWdvcmljYWwgbWFya2V0IHVzaW5nIHRyZWFzdXJ5IGNhcGl0YWwuIFNoYXJlcyB0aGUgcGVyLXRyYWRlIGFuZApwZXItbWFya2V0IGNhcHMgd2l0aCB0aGUgYmluYXJ5IHBhdGgsIGFuZCBuZWVkcyB0aGUgc2FtZSBuZXN0ZWQKdHJhbnNmZXIgcHJlLWF1dGhvcml6YXRpb24gKHNlZSBgZG9fYWdlbnRfYnV5YCkuAAAAAAAADWFnZW50X2J1eV9jYXQAAAAAAAAEAAAAAAAAAAltYXJrZXRfaWQAAAAAAAAGAAAAAAAAAA1vdXRjb21lX2luZGV4AAAAAAAABAAAAAAAAAANY29sbGF0ZXJhbF9pbgAAAAAAAAsAAAAAAAAADm1pbl9zaGFyZXNfb3V0AAAAAAALAAAAAQAAA+kAAAALAAAAAw==", "AAAAAAAAAKJgYWdlbnRfc2VsbGAgd2l0aCBhIHBlci1zaGFyZSBsaW1pdCBwcmljZSAoYGxpbWl0X3ByaWNlX2Jwc2AsIDAgPSBubwpsaW1pdCkgYW5kIGEgZGVhZGxpbmUgKHVuaXggc2Vjb25kcywgbmV0d29yayB0aW1lKSwgYm90aCBlbmZvcmNlZCBieQptYXJrZXQtY29yZSdzIGBzZWxsX3YyYC4AAAAAAA1hZ2VudF9zZWxsX3YyAAAAAAAABgAAAAAAAAAJbWFya2V0X2lkAAAAAAAABgAAAAAAAAAHb3V0Y29tZQAAAAfQAAAAB091dGNvbWUAAAAAAAAAAA5jb2xsYXRlcmFsX291dAAAAAAACwAAAAAAAAANbWF4X3NoYXJlc19pbgAAAAAAAAsAAAAAAAAAD2xpbWl0X3ByaWNlX2JwcwAAAAAEAAAAAAAAAAhkZWFkbGluZQAAAAYAAAABAAAD6QAAAAsAAAAD", "AAAAAAAAALRTZXRzIHRoZSBzdWNjZXNzb3IgdHJlYXN1cnkgdGhhdCBgbWlncmF0ZV9tYXJrZXRgIGhhbmRzIHBvc2l0aW9ucyBhbmQKdGhlIExQIHJvbGUgdG8uIEFkbWluLW9ubHk7IG92ZXJ3cml0ZXMgYW55IHByZXZpb3VzIHN1Y2Nlc3NvciAodGhlCmBTdWNjZXNzb3JTZXRgIGV2ZW50cyBhcmUgdGhlIGF1ZGl0IHRyYWlsKS4AAAANc2V0X3N1Y2Nlc3NvcgAAAAAAAAEAAAAAAAAACXN1Y2Nlc3NvcgAAAAAAABMAAAABAAAD6QAAAAIAAAAD", "AAAAAAAAAqxNb3ZlcyB0aGUgdHJlYXN1cnkncyBwb3NpdGlvbiBpbiBgbWFya2V0X2lkYCDigJQgYW5kIHRoZSBMUCByb2xlLCB3aGVuCnRoZSB0cmVhc3VyeSBpcyB0aGUgbWFya2V0J3MgY3JlYXRvciBhbmQgaGFzIG5vdCBjbGFpbWVkIHRoZSByZW1haW5kZXIKeWV0IOKAlCB0byB0aGUgc3VjY2Vzc29yIHNldCB2aWEgYHNldF9zdWNjZXNzb3JgLCB0aGVuIGNsb3NlcyB0aGUgbG9jYWwKZXhwb3N1cmUuIEFkbWluLW9ubHk6IHJlbG9jYXRpbmcgY2FwaXRhbCBpcyBub3QgYW4gYWdlbnQgdHJhZGluZwphY3Rpb24sIGFuZCB0aGUgYWdlbnQga2V5IG11c3Qgbm90IGJlIGFibGUgdG8gZHJhaW4gcG9zaXRpb25zLgoKbWFya2V0LWNvcmUncyBgdHJhbnNmZXJfcG9zaXRpb25gIC8gYHRyYW5zZmVyX2xwYCByZXF1aXJlIHRoZSB0cmVhc3VyeQppdHNlbGYgdG8gYXV0aG9yaXplLiBCZWNhdXNlIHRoZSB0cmVhc3VyeSBpcyB0aGVpciAqZGlyZWN0KiBjYWxsZXIKaGVyZSwgdGhlIGltcGxpY2l0IGludm9rZXIgYXV0aG9yaXphdGlvbiBjb3ZlcnMgYm90aCDigJQgbm8KYGF1dGhvcml6ZV9hc19jdXJyZW50X2NvbnRyYWN0YCBlbnRyeSBpcyBuZWVkZWQgKHVubGlrZSB0aGUgbmVzdGVkCnRva2VuIHRyYW5zZmVyIGluIGBhZ2VudF9idXlgLCBzZWUgYGRvY3MvYWRyLzAwMDItdHJlYXN1cnktbmVzdGVkLWF1dGgubWRgKS4AAAAObWlncmF0ZV9tYXJrZXQAAAAAAAEAAAAAAAAACW1hcmtldF9pZAAAAAAAAAYAAAABAAAD6QAAAAIAAAAD", @@ -269,6 +279,7 @@ export class Client extends ContractClient { close_trade: this.txFromJSON>, daily_usage: this.txFromJSON>, agent_buy_v2: this.txFromJSON>, + agent_buy_cat: this.txFromJSON>, agent_sell_v2: this.txFromJSON>, set_successor: this.txFromJSON>, migrate_market: this.txFromJSON>, diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index 9b7c33a..a364f52 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -6,8 +6,9 @@ //! `spec_v2_hash`, so the tool cannot drift from the contract. use resolver::{ - legacy_spec_hash, spec_v2_hash, DisputableSignerConfig, Escalation, OracleAsset, PriceConfig, - PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, WeightedKey, WeightedKeyConfig, + legacy_spec_hash, spec_v2_hash, CatSignerConfig, DisputableSignerConfig, Escalation, + OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, ResolutionSpecV2, ScalarPriceConfig, + SignerConfig, WeightedKey, WeightedKeyConfig, }; use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec}; @@ -21,7 +22,12 @@ usage: spec-hash [V2 FLAGS] signers-disputable ... is `guardian` or `committee::,...` - spec-hash [V2 FLAGS] verify ... + spec-hash [V2 FLAGS] cat-signers ... + is 2..=8, the number of outcomes of the categorical market + spec-hash [V2 FLAGS] scalar + is `stellar:` or `other:` + LONG pays `(price - lower) / (upper - lower)` in bps, clamped into the range + spec-hash [V2 FLAGS] verify ... spec-hash gen-salt V2 FLAGS (all four are required together; without them the legacy hash is printed): @@ -109,19 +115,23 @@ pub fn parse_signers(env: &Env, args: &[String]) -> Result Result { + if let Some(address) = value.strip_prefix("stellar:") { + Ok(OracleAsset::Stellar(parse_address(env, address)?)) + } else if let Some(symbol) = value.strip_prefix("other:") { + Ok(OracleAsset::Other(Symbol::new(env, symbol))) + } else { + Err("asset must be `stellar:

` or `other:`".into()) + } +} + pub fn parse_price(env: &Env, args: &[String]) -> Result { let [reflector, asset, threshold, direction, max_staleness] = args else { return Err( "price needs ".into(), ); }; - let asset = if let Some(address) = asset.strip_prefix("stellar:") { - OracleAsset::Stellar(parse_address(env, address)?) - } else if let Some(symbol) = asset.strip_prefix("other:") { - OracleAsset::Other(Symbol::new(env, symbol)) - } else { - return Err("asset must be `stellar:
` or `other:`".into()); - }; + let asset = parse_asset(env, asset)?; let direction = match direction.as_str() { "above" => PriceDirection::Above, "at-or-above" => PriceDirection::AtOrAbove, @@ -146,6 +156,25 @@ pub fn parse_price(env: &Env, args: &[String]) -> Result })) } +pub fn parse_scalar(env: &Env, args: &[String]) -> Result { + let [reflector, asset, lower, upper, max_staleness] = args else { + return Err("scalar needs ".into()); + }; + Ok(ResolutionSpec::Scalar(ScalarPriceConfig { + reflector: parse_address(env, reflector)?, + asset: parse_asset(env, asset)?, + lower: lower + .parse() + .map_err(|_| "lower must be an i128".to_string())?, + upper: upper + .parse() + .map_err(|_| "upper must be an i128".to_string())?, + max_staleness: max_staleness + .parse() + .map_err(|_| "max_staleness must be a u64".to_string())?, + })) +} + pub fn parse_weighted_keys(env: &Env, args: &[String]) -> Result { let [weight_threshold, dispute_window, keys @ ..] = args else { return Err( @@ -229,13 +258,45 @@ pub fn parse_signers_disputable(env: &Env, args: &[String]) -> Result Result { + let [outcome_count, threshold, dispute_window, signers @ ..] = args else { + return Err( + "cat-signers needs ...".into(), + ); + }; + if signers.is_empty() { + return Err("cat-signers needs at least one signer address".into()); + } + let mut list = SorobanVec::new(env); + for signer in signers { + list.push_back(parse_address(env, signer)?); + } + Ok(ResolutionSpec::CategoricalSigners(CatSignerConfig { + signers: list, + threshold: threshold + .parse() + .map_err(|_| "threshold must be a u32".to_string())?, + dispute_window: dispute_window + .parse() + .map_err(|_| "dispute_window must be a u64".to_string())?, + outcome_count: outcome_count + .parse() + .map_err(|_| "outcome_count must be a u32".to_string())?, + })) +} + fn parse_rule(env: &Env, args: &[String]) -> Result { match args { [kind, rest @ ..] if kind == "signers" => parse_signers(env, rest), [kind, rest @ ..] if kind == "price" => parse_price(env, rest), [kind, rest @ ..] if kind == "weighted-keys" => parse_weighted_keys(env, rest), [kind, rest @ ..] if kind == "signers-disputable" => parse_signers_disputable(env, rest), - _ => Err("expected `signers`, `price`, `weighted-keys` or `signers-disputable`".into()), + [kind, rest @ ..] if kind == "cat-signers" => parse_cat_signers(env, rest), + [kind, rest @ ..] if kind == "scalar" => parse_scalar(env, rest), + _ => Err( + "expected `signers`, `price`, `weighted-keys`, `signers-disputable`, `cat-signers` or `scalar`" + .into(), + ), } } @@ -408,6 +469,26 @@ mod tests { ); } + /// The same vectors as `golden_legacy_cat_signer_spec_hashes` in + /// contracts/resolver: CLI parsing and the contract types must produce + /// byte-identical commitments. + #[test] + fn golden_cat_signers_vectors() { + let vectors: [(&[&str], &str); 2] = [ + ( + &["cat-signers", "2", "1", "5", G1], + "16db57d51742d266dccd1a70539afc160971c942605a0ae8f15b5a18cd1b2837", + ), + ( + &["cat-signers", "3", "2", "3600", G1, G2, G3], + "21b750237b5cb3f54bdc71ec55a1d8073a3b860f6cb3cb3b9717570dc42092e7", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hash_of(spec), expected); + } + } + /// Re-derived from hand-built XDR (no Soroban) when these vectors were added. #[test] fn golden_signers_disputable_vectors() { @@ -443,6 +524,38 @@ mod tests { } } + /// The same vectors as `golden_legacy_scalar_spec_hashes` in + /// contracts/resolver: CLI parsing and the contract types must produce + /// byte-identical commitments, and the scalar space must not collide with + /// the price space it mirrors. + #[test] + fn golden_scalar_vectors() { + let vectors: [(&[&str], &str); 2] = [ + ( + &["scalar", C1, "other:XLM", "100", "250", "10"], + "e97c4bccc39a4538850b22e703019c3b69971a00b99bc73117cc67e044e26822", + ), + ( + &[ + "scalar", + C1, + &format!("stellar:{C2}"), + "0", + "1000000000000", + "300", + ], + "1d94380e9112c6f80f7542106dcc75ffc1426a634da2a07440fac72d2033fa08", + ), + ]; + for (spec, expected) in vectors { + assert_eq!(hash_of(spec), expected); + } + assert_ne!( + hash_of(&["scalar", C1, "other:XLM", "100", "250", "10"]), + hash_of(&["price", C1, "other:XLM", "2000", "above", "10"]), + ); + } + const SALT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; const V2_GOLDEN: &str = "27e995544f9f631be41177890b7a1be7e17c667e4e531aa08e5089dd899566ae"; @@ -530,6 +643,13 @@ mod tests { args(&["signers", "x", "5", G1]), args(&["signers", "1", "5", "not-an-address"]), args(&["price", C1, "bogus", "1", "above", "1"]), + args(&["cat-signers", "3", "2", "100"]), + args(&["cat-signers", "x", "2", "100", G1]), + args(&["cat-signers", "3", "2", "100", "not-an-address"]), + args(&["scalar", C1, "other:XLM", "100", "250"]), + args(&["scalar", C1, "bogus", "100", "250", "10"]), + args(&["scalar", C1, "other:XLM", "x", "250", "10"]), + args(&["scalar", C1, "other:XLM", "100", "250", "x"]), args(&["weighted-keys", "2", "60", "nocolon"]), args(&["weighted-keys", "2", "60", "zz:1"]), args(&[ From a0ca3ee805db363ef7862ca3259791455d8336df Mon Sep 17 00:00:00 2001 From: MFrancis-dev Date: Mon, 19 Oct 2026 11:27:28 +0100 Subject: [PATCH 3/3] docs(status): re-measure contributor test counts after the develop merge The addendum's figures were measured pre-merge (208 workspace tests, 52 agents). On the merged branch: 305 (market-core 97, resolver 107, treasury 95, reasoning-registry 6) and 54 agents tests. --- oracledesk-stellar/docs/STATUS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/oracledesk-stellar/docs/STATUS.md b/oracledesk-stellar/docs/STATUS.md index 9f0e7e1..b455d03 100644 --- a/oracledesk-stellar/docs/STATUS.md +++ b/oracledesk-stellar/docs/STATUS.md @@ -44,13 +44,13 @@ also gained categorical signer resolution (`register_cat_signer_spec` / forwarder. Design and invariants: `docs/architecture.md`; storage and append-only-XDR decision: [ADR 0006](adr/0006-market-kind-storage-split.md). -Verified in the contributor session: `cargo fmt --check`, `cargo clippy ---workspace --all-targets -- -D warnings`, `cargo test --workspace` (now -**208** tests: market-core 97, resolver 82, treasury 23, reasoning-registry -6 — superseding the 87-test count above), the `scripts/spec-hash` crate (11 -tests), `python3 model/fpmm_model.py`, agents (52 tests) and x402 (5 -tests), plus `make build` (vendored wasm regenerated) and `make bindings` -with only the intended diffs. The new math is mirrored in all three +Verified in the contributor session, re-measured after merging `develop`: +`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, +`cargo test --workspace` (now **305** tests: market-core 97, resolver 107, +treasury 95, reasoning-registry 6 — superseding the 87-test count above), the +`scripts/spec-hash` crate (11 tests), `python3 model/fpmm_model.py`, agents (54 +tests) and x402 (5 tests), plus `make build` (vendored wasm regenerated) and +`make bindings` with only the intended diffs. The new math is mirrored in all three languages against one shared fixture file (`model/fee_fixtures.json`) that Python, Rust and TypeScript all read. Worst-case categorical cost was measured, not estimated: `buy_cat` at `n = 8` takes 444,604 instructions /